재고 상세 다이얼로그화 및 마스터 레이아웃 개선
This commit is contained in:
@@ -203,9 +203,11 @@ class _DashboardPageState extends State<DashboardPage> {
|
||||
return Flex(
|
||||
direction: showSidePanel ? Axis.horizontal : Axis.vertical,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: showSidePanel ? MainAxisSize.max : MainAxisSize.min,
|
||||
children: [
|
||||
Expanded(
|
||||
Flexible(
|
||||
flex: 3,
|
||||
fit: showSidePanel ? FlexFit.tight : FlexFit.loose,
|
||||
child: _RecentTransactionsCard(
|
||||
transactions: summary.recentTransactions,
|
||||
),
|
||||
@@ -216,6 +218,7 @@ class _DashboardPageState extends State<DashboardPage> {
|
||||
const SizedBox(height: 16),
|
||||
Flexible(
|
||||
flex: 2,
|
||||
fit: showSidePanel ? FlexFit.tight : FlexFit.loose,
|
||||
child: _PendingApprovalCard(
|
||||
approvals: summary.pendingApprovals,
|
||||
),
|
||||
|
||||
@@ -28,8 +28,10 @@ import 'package:superport_v2/features/inventory/transactions/domain/entities/sto
|
||||
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
|
||||
import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart';
|
||||
import 'package:superport_v2/features/inventory/transactions/presentation/services/transaction_detail_sync_service.dart';
|
||||
import 'package:superport_v2/features/inventory/transactions/presentation/widgets/transaction_detail_dialog.dart';
|
||||
import '../../../lookups/domain/entities/lookup_item.dart';
|
||||
import '../../../lookups/domain/repositories/inventory_lookup_repository.dart';
|
||||
import '../widgets/inbound_detail_view.dart';
|
||||
|
||||
const String _inboundTransactionTypeId = '입고';
|
||||
|
||||
@@ -456,6 +458,7 @@ class _InboundPageState extends State<InboundPage> {
|
||||
selected: _selectedRecord,
|
||||
onSelect: (record) {
|
||||
setState(() => _selectedRecord = record);
|
||||
_showDetailDialog(record);
|
||||
},
|
||||
dateFormatter: _dateFormatter,
|
||||
currencyFormatter: _currencyFormatter,
|
||||
@@ -572,51 +575,6 @@ class _InboundPageState extends State<InboundPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_selectedRecord != null)
|
||||
ResponsiveVisibility(
|
||||
visibleOn: const {
|
||||
DeviceBreakpoint.tablet,
|
||||
DeviceBreakpoint.desktop,
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 24),
|
||||
_DetailCard(
|
||||
record: _selectedRecord!,
|
||||
dateFormatter: _dateFormatter,
|
||||
currencyFormatter: _currencyFormatter,
|
||||
onEdit: () => _handleEdit(_selectedRecord!),
|
||||
transitionsEnabled: _transitionsEnabled,
|
||||
onSubmit: _canSubmit(_selectedRecord!)
|
||||
? () async => _submitRecord(_selectedRecord!)
|
||||
: null,
|
||||
onComplete: _canComplete(_selectedRecord!)
|
||||
? () async => _completeRecord(_selectedRecord!)
|
||||
: null,
|
||||
onApprove: _canApprove(_selectedRecord!)
|
||||
? () async => _approveRecord(_selectedRecord!)
|
||||
: null,
|
||||
onReject: _canReject(_selectedRecord!)
|
||||
? () async => _rejectRecord(_selectedRecord!)
|
||||
: null,
|
||||
onCancel: _canCancel(_selectedRecord!)
|
||||
? () async => _cancelRecord(_selectedRecord!)
|
||||
: null,
|
||||
canSubmit:
|
||||
!_isProcessing(_selectedRecord!.id) && !_isLoading,
|
||||
canComplete:
|
||||
!_isProcessing(_selectedRecord!.id) && !_isLoading,
|
||||
canApprove:
|
||||
!_isProcessing(_selectedRecord!.id) && !_isLoading,
|
||||
canReject:
|
||||
!_isProcessing(_selectedRecord!.id) && !_isLoading,
|
||||
canCancel:
|
||||
!_isProcessing(_selectedRecord!.id) && !_isLoading,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -704,9 +662,11 @@ class _InboundPageState extends State<InboundPage> {
|
||||
rowSpanExtent: (index) =>
|
||||
const FixedTableSpanExtent(InboundTableSpec.rowSpanHeight),
|
||||
onRowTap: (rowIndex) {
|
||||
final record = records[rowIndex];
|
||||
setState(() {
|
||||
_selectedRecord = records[rowIndex];
|
||||
_selectedRecord = record;
|
||||
});
|
||||
_showDetailDialog(record);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -728,6 +688,82 @@ class _InboundPageState extends State<InboundPage> {
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _showDetailDialog(InboundRecord record) async {
|
||||
await showInventoryTransactionDetailDialog<void>(
|
||||
context: context,
|
||||
title: '입고 상세',
|
||||
transactionNumber: record.transactionNumber,
|
||||
body: InboundDetailView(
|
||||
record: record,
|
||||
dateFormatter: _dateFormatter,
|
||||
currencyFormatter: _currencyFormatter,
|
||||
transitionsEnabled: _transitionsEnabled,
|
||||
),
|
||||
actions: _buildDetailActions(record),
|
||||
constraints: const BoxConstraints(maxWidth: 920),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildDetailActions(InboundRecord record) {
|
||||
final isProcessing = _isProcessing(record.id) || _isLoading;
|
||||
final actions = <Widget>[];
|
||||
|
||||
if (_canSubmit(record)) {
|
||||
actions.add(
|
||||
ShadButton.outline(
|
||||
onPressed: isProcessing ? null : () => _submitRecord(record),
|
||||
child: const Text('상신'),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_canApprove(record)) {
|
||||
actions.add(
|
||||
ShadButton.outline(
|
||||
onPressed: isProcessing ? null : () => _approveRecord(record),
|
||||
child: const Text('승인'),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_canReject(record)) {
|
||||
actions.add(
|
||||
ShadButton.outline(
|
||||
onPressed: isProcessing ? null : () => _rejectRecord(record),
|
||||
child: const Text('반려'),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_canCancel(record)) {
|
||||
actions.add(
|
||||
ShadButton.outline(
|
||||
onPressed: isProcessing ? null : () => _cancelRecord(record),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_canComplete(record)) {
|
||||
actions.add(
|
||||
ShadButton.outline(
|
||||
onPressed: isProcessing ? null : () => _completeRecord(record),
|
||||
child: const Text('완료 처리'),
|
||||
),
|
||||
);
|
||||
}
|
||||
actions.add(
|
||||
ShadButton.outline(
|
||||
leading: const Icon(lucide.LucideIcons.pencil, size: 16),
|
||||
onPressed: isProcessing
|
||||
? null
|
||||
: () {
|
||||
Navigator.of(context).maybePop();
|
||||
_handleEdit(record);
|
||||
},
|
||||
child: const Text('수정'),
|
||||
),
|
||||
);
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
Future<void> _handleCreate() async {
|
||||
final record = await _showInboundFormDialog();
|
||||
if (record != null) {
|
||||
@@ -1900,208 +1936,6 @@ class _InboundPageState extends State<InboundPage> {
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailCard extends StatelessWidget {
|
||||
const _DetailCard({
|
||||
required this.record,
|
||||
required this.dateFormatter,
|
||||
required this.currencyFormatter,
|
||||
required this.onEdit,
|
||||
this.transitionsEnabled = true,
|
||||
this.onSubmit,
|
||||
this.onComplete,
|
||||
this.onApprove,
|
||||
this.onReject,
|
||||
this.onCancel,
|
||||
this.canSubmit = true,
|
||||
this.canComplete = true,
|
||||
this.canApprove = true,
|
||||
this.canReject = true,
|
||||
this.canCancel = true,
|
||||
});
|
||||
|
||||
final InboundRecord record;
|
||||
final DateFormat dateFormatter;
|
||||
final NumberFormat currencyFormatter;
|
||||
final VoidCallback onEdit;
|
||||
final bool transitionsEnabled;
|
||||
final Future<void> Function()? onSubmit;
|
||||
final Future<void> Function()? onComplete;
|
||||
final Future<void> Function()? onApprove;
|
||||
final Future<void> Function()? onReject;
|
||||
final Future<void> Function()? onCancel;
|
||||
final bool canSubmit;
|
||||
final bool canComplete;
|
||||
final bool canApprove;
|
||||
final bool canReject;
|
||||
final bool canCancel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
|
||||
return ShadCard(
|
||||
title: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('선택된 입고 상세', style: theme.textTheme.h3),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (onSubmit != null)
|
||||
ShadButton.outline(
|
||||
onPressed: canSubmit
|
||||
? () {
|
||||
onSubmit?.call();
|
||||
}
|
||||
: null,
|
||||
child: const Text('상신'),
|
||||
),
|
||||
if (onApprove != null)
|
||||
ShadButton.outline(
|
||||
onPressed: canApprove
|
||||
? () {
|
||||
onApprove?.call();
|
||||
}
|
||||
: null,
|
||||
child: const Text('승인'),
|
||||
),
|
||||
if (onReject != null)
|
||||
ShadButton.outline(
|
||||
onPressed: canReject
|
||||
? () {
|
||||
onReject?.call();
|
||||
}
|
||||
: null,
|
||||
child: const Text('반려'),
|
||||
),
|
||||
if (onCancel != null)
|
||||
ShadButton.outline(
|
||||
onPressed: canCancel
|
||||
? () {
|
||||
onCancel?.call();
|
||||
}
|
||||
: null,
|
||||
child: const Text('취소'),
|
||||
),
|
||||
if (onComplete != null)
|
||||
ShadButton.outline(
|
||||
onPressed: canComplete
|
||||
? () {
|
||||
onComplete?.call();
|
||||
}
|
||||
: null,
|
||||
child: const Text('완료 처리'),
|
||||
),
|
||||
ShadButton.outline(
|
||||
leading: const Icon(lucide.LucideIcons.pencil, size: 16),
|
||||
onPressed: onEdit,
|
||||
child: const Text('수정'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
description: Text(
|
||||
'트랜잭션번호 ${record.transactionNumber}',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!transitionsEnabled) ...[
|
||||
ShadBadge.outline(
|
||||
child: Text(
|
||||
'재고 상태 전이가 비활성화된 상태입니다.',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_DetailChip(
|
||||
label: '처리일자',
|
||||
value: dateFormatter.format(record.processedAt),
|
||||
),
|
||||
_DetailChip(label: '창고', value: record.warehouse),
|
||||
_DetailChip(label: '트랜잭션 유형', value: record.transactionType),
|
||||
_DetailChip(label: '상태', value: record.status),
|
||||
_DetailChip(label: '작성자', value: record.writer),
|
||||
_DetailChip(label: '품목 수', value: '${record.itemCount}'),
|
||||
_DetailChip(label: '총 수량', value: '${record.totalQuantity}'),
|
||||
_DetailChip(
|
||||
label: '총 금액',
|
||||
value: currencyFormatter.format(record.totalAmount),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('라인 품목', style: theme.textTheme.h4),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: (record.items.length * 52).clamp(160, 260).toDouble(),
|
||||
child: ShadTable.list(
|
||||
header: const [
|
||||
ShadTableCell.header(child: Text('제품')),
|
||||
ShadTableCell.header(child: Text('제조사')),
|
||||
ShadTableCell.header(child: Text('단위')),
|
||||
ShadTableCell.header(child: Text('수량')),
|
||||
ShadTableCell.header(child: Text('단가')),
|
||||
ShadTableCell.header(child: Text('비고')),
|
||||
],
|
||||
children: [
|
||||
for (final item in record.items)
|
||||
[
|
||||
ShadTableCell(child: Text(item.product)),
|
||||
ShadTableCell(child: Text(item.manufacturer)),
|
||||
ShadTableCell(child: Text(item.unit)),
|
||||
ShadTableCell(child: Text('${item.quantity}')),
|
||||
ShadTableCell(
|
||||
child: Text(currencyFormatter.format(item.price)),
|
||||
),
|
||||
ShadTableCell(
|
||||
child: Text(item.remark.isEmpty ? '-' : item.remark),
|
||||
),
|
||||
],
|
||||
],
|
||||
columnSpanExtent: (index) => const FixedTableSpanExtent(136),
|
||||
rowSpanExtent: (index) => const FixedTableSpanExtent(52),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailChip extends StatelessWidget {
|
||||
const _DetailChip({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return ShadBadge.outline(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label, style: theme.textTheme.small),
|
||||
const SizedBox(height: 2),
|
||||
Text(value, style: theme.textTheme.p),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SummaryBadge extends StatelessWidget {
|
||||
const _SummaryBadge({
|
||||
required this.icon,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,10 +28,12 @@ import 'package:superport_v2/features/inventory/transactions/domain/entities/sto
|
||||
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
|
||||
import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart';
|
||||
import 'package:superport_v2/features/inventory/transactions/presentation/services/transaction_detail_sync_service.dart';
|
||||
import 'package:superport_v2/features/inventory/transactions/presentation/widgets/transaction_detail_dialog.dart';
|
||||
import 'package:superport_v2/features/masters/customer/domain/entities/customer.dart';
|
||||
import 'package:superport_v2/features/masters/customer/domain/repositories/customer_repository.dart';
|
||||
import '../../../lookups/domain/entities/lookup_item.dart';
|
||||
import '../../../lookups/domain/repositories/inventory_lookup_repository.dart';
|
||||
import '../widgets/outbound_detail_view.dart';
|
||||
|
||||
const String _outboundTransactionTypeId = '출고';
|
||||
|
||||
@@ -577,9 +579,11 @@ class _OutboundPageState extends State<OutboundPage> {
|
||||
OutboundTableSpec.rowSpanHeight,
|
||||
),
|
||||
onRowTap: (rowIndex) {
|
||||
final record = visibleRecords[rowIndex];
|
||||
setState(() {
|
||||
_selectedRecord = visibleRecords[rowIndex];
|
||||
_selectedRecord = record;
|
||||
});
|
||||
_showDetailDialog(record);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -672,36 +676,6 @@ class _OutboundPageState extends State<OutboundPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_selectedRecord != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
_OutboundDetailCard(
|
||||
record: _selectedRecord!,
|
||||
dateFormatter: _dateFormatter,
|
||||
currencyFormatter: _currencyFormatter,
|
||||
onEdit: () => _handleEdit(_selectedRecord!),
|
||||
transitionsEnabled: _transitionsEnabled,
|
||||
onSubmit: _canSubmit(_selectedRecord!)
|
||||
? () async => _submitRecord(_selectedRecord!)
|
||||
: null,
|
||||
onComplete: _canComplete(_selectedRecord!)
|
||||
? () async => _completeRecord(_selectedRecord!)
|
||||
: null,
|
||||
onApprove: _canApprove(_selectedRecord!)
|
||||
? () async => _approveRecord(_selectedRecord!)
|
||||
: null,
|
||||
onReject: _canReject(_selectedRecord!)
|
||||
? () async => _rejectRecord(_selectedRecord!)
|
||||
: null,
|
||||
onCancel: _canCancel(_selectedRecord!)
|
||||
? () async => _cancelRecord(_selectedRecord!)
|
||||
: null,
|
||||
canSubmit: !_isProcessing(_selectedRecord!.id) && !_isLoading,
|
||||
canComplete: !_isProcessing(_selectedRecord!.id) && !_isLoading,
|
||||
canApprove: !_isProcessing(_selectedRecord!.id) && !_isLoading,
|
||||
canReject: !_isProcessing(_selectedRecord!.id) && !_isLoading,
|
||||
canCancel: !_isProcessing(_selectedRecord!.id) && !_isLoading,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -795,6 +769,82 @@ class _OutboundPageState extends State<OutboundPage> {
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _showDetailDialog(OutboundRecord record) async {
|
||||
await showInventoryTransactionDetailDialog<void>(
|
||||
context: context,
|
||||
title: '출고 상세',
|
||||
transactionNumber: record.transactionNumber,
|
||||
body: OutboundDetailView(
|
||||
record: record,
|
||||
dateFormatter: _dateFormatter,
|
||||
currencyFormatter: _currencyFormatter,
|
||||
transitionsEnabled: _transitionsEnabled,
|
||||
),
|
||||
actions: _buildDetailActions(record),
|
||||
constraints: const BoxConstraints(maxWidth: 920),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildDetailActions(OutboundRecord record) {
|
||||
final isProcessing = _isProcessing(record.id) || _isLoading;
|
||||
final actions = <Widget>[];
|
||||
|
||||
if (_canSubmit(record)) {
|
||||
actions.add(
|
||||
ShadButton.outline(
|
||||
onPressed: isProcessing ? null : () => _submitRecord(record),
|
||||
child: const Text('상신'),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_canApprove(record)) {
|
||||
actions.add(
|
||||
ShadButton.outline(
|
||||
onPressed: isProcessing ? null : () => _approveRecord(record),
|
||||
child: const Text('승인'),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_canReject(record)) {
|
||||
actions.add(
|
||||
ShadButton.outline(
|
||||
onPressed: isProcessing ? null : () => _rejectRecord(record),
|
||||
child: const Text('반려'),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_canCancel(record)) {
|
||||
actions.add(
|
||||
ShadButton.outline(
|
||||
onPressed: isProcessing ? null : () => _cancelRecord(record),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_canComplete(record)) {
|
||||
actions.add(
|
||||
ShadButton.outline(
|
||||
onPressed: isProcessing ? null : () => _completeRecord(record),
|
||||
child: const Text('출고 완료'),
|
||||
),
|
||||
);
|
||||
}
|
||||
actions.add(
|
||||
ShadButton.outline(
|
||||
leading: const Icon(lucide.LucideIcons.pencil, size: 16),
|
||||
onPressed: isProcessing
|
||||
? null
|
||||
: () {
|
||||
Navigator.of(context).maybePop();
|
||||
_handleEdit(record);
|
||||
},
|
||||
child: const Text('수정'),
|
||||
),
|
||||
);
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
Future<void> _handleCreate() async {
|
||||
final record = await _showOutboundFormDialog();
|
||||
if (record != null) {
|
||||
@@ -2122,202 +2172,6 @@ class _OutboundPageState extends State<OutboundPage> {
|
||||
}
|
||||
}
|
||||
|
||||
class _OutboundDetailCard extends StatelessWidget {
|
||||
const _OutboundDetailCard({
|
||||
required this.record,
|
||||
required this.dateFormatter,
|
||||
required this.currencyFormatter,
|
||||
required this.onEdit,
|
||||
this.transitionsEnabled = true,
|
||||
this.onSubmit,
|
||||
this.onComplete,
|
||||
this.onApprove,
|
||||
this.onReject,
|
||||
this.onCancel,
|
||||
this.canSubmit = true,
|
||||
this.canComplete = true,
|
||||
this.canApprove = true,
|
||||
this.canReject = true,
|
||||
this.canCancel = true,
|
||||
});
|
||||
|
||||
final OutboundRecord record;
|
||||
final DateFormat dateFormatter;
|
||||
final NumberFormat currencyFormatter;
|
||||
final VoidCallback onEdit;
|
||||
final bool transitionsEnabled;
|
||||
final Future<void> Function()? onSubmit;
|
||||
final Future<void> Function()? onComplete;
|
||||
final Future<void> Function()? onApprove;
|
||||
final Future<void> Function()? onReject;
|
||||
final Future<void> Function()? onCancel;
|
||||
final bool canSubmit;
|
||||
final bool canComplete;
|
||||
final bool canApprove;
|
||||
final bool canReject;
|
||||
final bool canCancel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
|
||||
return ShadCard(
|
||||
title: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('선택된 출고 상세', style: theme.textTheme.h3),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (onSubmit != null)
|
||||
ShadButton.outline(
|
||||
onPressed: canSubmit
|
||||
? () {
|
||||
onSubmit?.call();
|
||||
}
|
||||
: null,
|
||||
child: const Text('상신'),
|
||||
),
|
||||
if (onApprove != null)
|
||||
ShadButton.outline(
|
||||
onPressed: canApprove
|
||||
? () {
|
||||
onApprove?.call();
|
||||
}
|
||||
: null,
|
||||
child: const Text('승인'),
|
||||
),
|
||||
if (onReject != null)
|
||||
ShadButton.outline(
|
||||
onPressed: canReject
|
||||
? () {
|
||||
onReject?.call();
|
||||
}
|
||||
: null,
|
||||
child: const Text('반려'),
|
||||
),
|
||||
if (onCancel != null)
|
||||
ShadButton.outline(
|
||||
onPressed: canCancel
|
||||
? () {
|
||||
onCancel?.call();
|
||||
}
|
||||
: null,
|
||||
child: const Text('취소'),
|
||||
),
|
||||
if (onComplete != null)
|
||||
ShadButton.outline(
|
||||
onPressed: canComplete
|
||||
? () {
|
||||
onComplete?.call();
|
||||
}
|
||||
: null,
|
||||
child: const Text('출고 완료'),
|
||||
),
|
||||
ShadButton.outline(
|
||||
leading: const Icon(lucide.LucideIcons.pencil, size: 16),
|
||||
onPressed: onEdit,
|
||||
child: const Text('수정'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
description: Text(
|
||||
'트랜잭션번호 ${record.transactionNumber}',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!transitionsEnabled) ...[
|
||||
ShadBadge.outline(
|
||||
child: Text(
|
||||
'재고 상태 전이가 비활성화된 상태입니다.',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_DetailChip(
|
||||
label: '처리일자',
|
||||
value: dateFormatter.format(record.processedAt),
|
||||
),
|
||||
_DetailChip(label: '창고', value: record.warehouse),
|
||||
_DetailChip(label: '트랜잭션 유형', value: record.transactionType),
|
||||
_DetailChip(label: '상태', value: record.status),
|
||||
_DetailChip(label: '작성자', value: record.writer),
|
||||
_DetailChip(label: '고객 수', value: '${record.customerCount}'),
|
||||
_DetailChip(label: '품목 수', value: '${record.itemCount}'),
|
||||
_DetailChip(label: '총 수량', value: '${record.totalQuantity}'),
|
||||
_DetailChip(
|
||||
label: '총 금액',
|
||||
value: currencyFormatter.format(record.totalAmount),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('출고 고객사', style: theme.textTheme.h4),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final customer in record.customers)
|
||||
ShadBadge(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 6,
|
||||
),
|
||||
child: Text('${customer.name} · ${customer.code}'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('라인 품목', style: theme.textTheme.h4),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: (record.items.length * 52).clamp(160, 260).toDouble(),
|
||||
child: ShadTable.list(
|
||||
header: const [
|
||||
ShadTableCell.header(child: Text('제품')),
|
||||
ShadTableCell.header(child: Text('제조사')),
|
||||
ShadTableCell.header(child: Text('단위')),
|
||||
ShadTableCell.header(child: Text('수량')),
|
||||
ShadTableCell.header(child: Text('단가')),
|
||||
ShadTableCell.header(child: Text('비고')),
|
||||
],
|
||||
children: [
|
||||
for (final item in record.items)
|
||||
[
|
||||
ShadTableCell(child: Text(item.product)),
|
||||
ShadTableCell(child: Text(item.manufacturer)),
|
||||
ShadTableCell(child: Text(item.unit)),
|
||||
ShadTableCell(child: Text('${item.quantity}')),
|
||||
ShadTableCell(
|
||||
child: Text(currencyFormatter.format(item.price)),
|
||||
),
|
||||
ShadTableCell(
|
||||
child: Text(item.remark.isEmpty ? '-' : item.remark),
|
||||
),
|
||||
],
|
||||
],
|
||||
columnSpanExtent: (index) => const FixedTableSpanExtent(136),
|
||||
rowSpanExtent: (index) => const FixedTableSpanExtent(52),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OutboundLineItemRow extends StatelessWidget {
|
||||
const _OutboundLineItemRow({
|
||||
required this.draft,
|
||||
@@ -2829,29 +2683,3 @@ enum _OutboundSortField {
|
||||
writer,
|
||||
customerCount,
|
||||
}
|
||||
|
||||
class _DetailChip extends StatelessWidget {
|
||||
const _DetailChip({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return ShadBadge.outline(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label, style: theme.textTheme.small),
|
||||
const SizedBox(height: 2),
|
||||
Text(value, style: theme.textTheme.p),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,10 @@ import 'package:superport_v2/features/inventory/transactions/domain/entities/sto
|
||||
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
|
||||
import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart';
|
||||
import 'package:superport_v2/features/inventory/transactions/presentation/services/transaction_detail_sync_service.dart';
|
||||
import 'package:superport_v2/features/inventory/transactions/presentation/widgets/transaction_detail_dialog.dart';
|
||||
import '../../../lookups/domain/entities/lookup_item.dart';
|
||||
import '../../../lookups/domain/repositories/inventory_lookup_repository.dart';
|
||||
import '../widgets/rental_detail_view.dart';
|
||||
|
||||
const String _rentalTransactionTypeRent = '대여';
|
||||
const String _rentalTransactionTypeReturn = '반납';
|
||||
@@ -522,9 +524,11 @@ class _RentalPageState extends State<RentalPage> {
|
||||
RentalTableSpec.rowSpanHeight,
|
||||
),
|
||||
onRowTap: (rowIndex) {
|
||||
final record = visibleRecords[rowIndex];
|
||||
setState(() {
|
||||
_selectedRecord = visibleRecords[rowIndex];
|
||||
_selectedRecord = record;
|
||||
});
|
||||
_showDetailDialog(record);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -619,36 +623,6 @@ class _RentalPageState extends State<RentalPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_selectedRecord != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
_RentalDetailCard(
|
||||
record: _selectedRecord!,
|
||||
dateFormatter: _dateFormatter,
|
||||
currencyFormatter: _currencyFormatter,
|
||||
onEdit: () => _handleEdit(_selectedRecord!),
|
||||
transitionsEnabled: _transitionsEnabled,
|
||||
onSubmit: _canSubmit(_selectedRecord!)
|
||||
? () async => _submitRecord(_selectedRecord!)
|
||||
: null,
|
||||
onComplete: _canComplete(_selectedRecord!)
|
||||
? () async => _completeRecord(_selectedRecord!)
|
||||
: null,
|
||||
onApprove: _canApprove(_selectedRecord!)
|
||||
? () async => _approveRecord(_selectedRecord!)
|
||||
: null,
|
||||
onReject: _canReject(_selectedRecord!)
|
||||
? () async => _rejectRecord(_selectedRecord!)
|
||||
: null,
|
||||
onCancel: _canCancel(_selectedRecord!)
|
||||
? () async => _cancelRecord(_selectedRecord!)
|
||||
: null,
|
||||
canSubmit: !_isProcessing(_selectedRecord!.id) && !_isLoading,
|
||||
canComplete: !_isProcessing(_selectedRecord!.id) && !_isLoading,
|
||||
canApprove: !_isProcessing(_selectedRecord!.id) && !_isLoading,
|
||||
canReject: !_isProcessing(_selectedRecord!.id) && !_isLoading,
|
||||
canCancel: !_isProcessing(_selectedRecord!.id) && !_isLoading,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -752,6 +726,82 @@ class _RentalPageState extends State<RentalPage> {
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _showDetailDialog(RentalRecord record) async {
|
||||
await showInventoryTransactionDetailDialog<void>(
|
||||
context: context,
|
||||
title: '대여 상세',
|
||||
transactionNumber: record.transactionNumber,
|
||||
body: RentalDetailView(
|
||||
record: record,
|
||||
dateFormatter: _dateFormatter,
|
||||
currencyFormatter: _currencyFormatter,
|
||||
transitionsEnabled: _transitionsEnabled,
|
||||
),
|
||||
actions: _buildDetailActions(record),
|
||||
constraints: const BoxConstraints(maxWidth: 920),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildDetailActions(RentalRecord record) {
|
||||
final isProcessing = _isProcessing(record.id) || _isLoading;
|
||||
final actions = <Widget>[];
|
||||
|
||||
if (_canSubmit(record)) {
|
||||
actions.add(
|
||||
ShadButton.outline(
|
||||
onPressed: isProcessing ? null : () => _submitRecord(record),
|
||||
child: const Text('반납 요청'),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_canApprove(record)) {
|
||||
actions.add(
|
||||
ShadButton.outline(
|
||||
onPressed: isProcessing ? null : () => _approveRecord(record),
|
||||
child: const Text('승인'),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_canReject(record)) {
|
||||
actions.add(
|
||||
ShadButton.outline(
|
||||
onPressed: isProcessing ? null : () => _rejectRecord(record),
|
||||
child: const Text('반려'),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_canCancel(record)) {
|
||||
actions.add(
|
||||
ShadButton.outline(
|
||||
onPressed: isProcessing ? null : () => _cancelRecord(record),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_canComplete(record)) {
|
||||
actions.add(
|
||||
ShadButton.outline(
|
||||
onPressed: isProcessing ? null : () => _completeRecord(record),
|
||||
child: const Text('대여 완료'),
|
||||
),
|
||||
);
|
||||
}
|
||||
actions.add(
|
||||
ShadButton.outline(
|
||||
leading: const Icon(lucide.LucideIcons.pencil, size: 16),
|
||||
onPressed: isProcessing
|
||||
? null
|
||||
: () {
|
||||
Navigator.of(context).maybePop();
|
||||
_handleEdit(record);
|
||||
},
|
||||
child: const Text('수정'),
|
||||
),
|
||||
);
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
Future<void> _handleCreate() async {
|
||||
final record = await _showRentalFormDialog();
|
||||
if (record != null) {
|
||||
@@ -2218,208 +2268,6 @@ enum _RentalSortField {
|
||||
totalQuantity,
|
||||
}
|
||||
|
||||
class _RentalDetailCard extends StatelessWidget {
|
||||
const _RentalDetailCard({
|
||||
required this.record,
|
||||
required this.dateFormatter,
|
||||
required this.currencyFormatter,
|
||||
required this.onEdit,
|
||||
this.transitionsEnabled = true,
|
||||
this.onSubmit,
|
||||
this.onComplete,
|
||||
this.onApprove,
|
||||
this.onReject,
|
||||
this.onCancel,
|
||||
this.canSubmit = true,
|
||||
this.canComplete = true,
|
||||
this.canApprove = true,
|
||||
this.canReject = true,
|
||||
this.canCancel = true,
|
||||
});
|
||||
|
||||
final RentalRecord record;
|
||||
final DateFormat dateFormatter;
|
||||
final NumberFormat currencyFormatter;
|
||||
final VoidCallback onEdit;
|
||||
final bool transitionsEnabled;
|
||||
final Future<void> Function()? onSubmit;
|
||||
final Future<void> Function()? onComplete;
|
||||
final Future<void> Function()? onApprove;
|
||||
final Future<void> Function()? onReject;
|
||||
final Future<void> Function()? onCancel;
|
||||
final bool canSubmit;
|
||||
final bool canComplete;
|
||||
final bool canApprove;
|
||||
final bool canReject;
|
||||
final bool canCancel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
|
||||
return ShadCard(
|
||||
title: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('선택된 대여 상세', style: theme.textTheme.h3),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (onSubmit != null)
|
||||
ShadButton.outline(
|
||||
onPressed: canSubmit
|
||||
? () {
|
||||
onSubmit?.call();
|
||||
}
|
||||
: null,
|
||||
child: const Text('반납 요청'),
|
||||
),
|
||||
if (onApprove != null)
|
||||
ShadButton.outline(
|
||||
onPressed: canApprove
|
||||
? () {
|
||||
onApprove?.call();
|
||||
}
|
||||
: null,
|
||||
child: const Text('승인'),
|
||||
),
|
||||
if (onReject != null)
|
||||
ShadButton.outline(
|
||||
onPressed: canReject
|
||||
? () {
|
||||
onReject?.call();
|
||||
}
|
||||
: null,
|
||||
child: const Text('반려'),
|
||||
),
|
||||
if (onCancel != null)
|
||||
ShadButton.outline(
|
||||
onPressed: canCancel
|
||||
? () {
|
||||
onCancel?.call();
|
||||
}
|
||||
: null,
|
||||
child: const Text('취소'),
|
||||
),
|
||||
if (onComplete != null)
|
||||
ShadButton.outline(
|
||||
onPressed: canComplete
|
||||
? () {
|
||||
onComplete?.call();
|
||||
}
|
||||
: null,
|
||||
child: const Text('대여 완료'),
|
||||
),
|
||||
ShadButton.outline(
|
||||
leading: const Icon(lucide.LucideIcons.pencil, size: 16),
|
||||
onPressed: onEdit,
|
||||
child: const Text('수정'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
description: Text(
|
||||
'트랜잭션번호 ${record.transactionNumber}',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!transitionsEnabled) ...[
|
||||
ShadBadge.outline(
|
||||
child: Text(
|
||||
'재고 상태 전이가 비활성화된 상태입니다.',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_DetailChip(
|
||||
label: '처리일자',
|
||||
value: dateFormatter.format(record.processedAt),
|
||||
),
|
||||
_DetailChip(label: '창고', value: record.warehouse),
|
||||
_DetailChip(label: '트랜잭션 유형', value: record.transactionType),
|
||||
_DetailChip(label: '대여 구분', value: record.rentalType),
|
||||
_DetailChip(label: '상태', value: record.status),
|
||||
_DetailChip(label: '작성자', value: record.writer),
|
||||
_DetailChip(
|
||||
label: '반납 예정일',
|
||||
value: record.returnDueDate == null
|
||||
? '-'
|
||||
: dateFormatter.format(record.returnDueDate!),
|
||||
),
|
||||
_DetailChip(label: '고객 수', value: '${record.customerCount}'),
|
||||
_DetailChip(label: '총 수량', value: '${record.totalQuantity}'),
|
||||
_DetailChip(
|
||||
label: '총 금액',
|
||||
value: currencyFormatter.format(record.totalAmount),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('연결 고객사', style: theme.textTheme.h4),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final customer in record.customers)
|
||||
ShadBadge(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 6,
|
||||
),
|
||||
child: Text('${customer.name} · ${customer.code}'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('라인 품목', style: theme.textTheme.h4),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: (record.items.length * 52).clamp(160, 260).toDouble(),
|
||||
child: ShadTable.list(
|
||||
header: const [
|
||||
ShadTableCell.header(child: Text('제품')),
|
||||
ShadTableCell.header(child: Text('제조사')),
|
||||
ShadTableCell.header(child: Text('단위')),
|
||||
ShadTableCell.header(child: Text('수량')),
|
||||
ShadTableCell.header(child: Text('단가')),
|
||||
ShadTableCell.header(child: Text('비고')),
|
||||
],
|
||||
children: [
|
||||
for (final item in record.items)
|
||||
[
|
||||
ShadTableCell(child: Text(item.product)),
|
||||
ShadTableCell(child: Text(item.manufacturer)),
|
||||
ShadTableCell(child: Text(item.unit)),
|
||||
ShadTableCell(child: Text('${item.quantity}')),
|
||||
ShadTableCell(
|
||||
child: Text(currencyFormatter.format(item.price)),
|
||||
),
|
||||
ShadTableCell(
|
||||
child: Text(item.remark.isEmpty ? '-' : item.remark),
|
||||
),
|
||||
],
|
||||
],
|
||||
columnSpanExtent: (index) => const FixedTableSpanExtent(136),
|
||||
rowSpanExtent: (index) => const FixedTableSpanExtent(52),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FormFieldLabel extends StatelessWidget {
|
||||
const _FormFieldLabel({required this.label, required this.child});
|
||||
|
||||
@@ -2881,29 +2729,3 @@ class _RentalSummaryBadge extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailChip extends StatelessWidget {
|
||||
const _DetailChip({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return ShadBadge.outline(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label, style: theme.textTheme.small),
|
||||
const SizedBox(height: 2),
|
||||
Text(value, style: theme.textTheme.p),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import 'package:superport_v2/widgets/components/filter_bar.dart';
|
||||
import 'package:superport_v2/widgets/components/superport_dialog.dart';
|
||||
import 'package:superport_v2/features/util/postal_search/presentation/models/postal_search_result.dart';
|
||||
import 'package:superport_v2/features/util/postal_search/presentation/widgets/postal_search_dialog.dart';
|
||||
import 'package:superport_v2/widgets/components/responsive_section.dart';
|
||||
|
||||
import '../../../../../core/config/environment.dart';
|
||||
import '../../../../../widgets/spec_page.dart';
|
||||
@@ -103,7 +104,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final FocusNode _searchFocus = FocusNode();
|
||||
String? _lastError;
|
||||
bool _routeApplied = false;
|
||||
String? _lastAppliedRoute;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -115,9 +116,14 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_routeApplied) {
|
||||
_routeApplied = true;
|
||||
_applyRouteParameters();
|
||||
_applyRouteIfNeeded();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _CustomerEnabledPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.routeUri != oldWidget.routeUri) {
|
||||
_applyRouteIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,8 +139,17 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
}
|
||||
}
|
||||
|
||||
void _applyRouteParameters() {
|
||||
final params = widget.routeUri.queryParameters;
|
||||
void _applyRouteIfNeeded() {
|
||||
final current = widget.routeUri.toString();
|
||||
if (_lastAppliedRoute == current) {
|
||||
return;
|
||||
}
|
||||
_lastAppliedRoute = current;
|
||||
_applyRouteParameters(widget.routeUri);
|
||||
}
|
||||
|
||||
void _applyRouteParameters(Uri route) {
|
||||
final params = route.queryParameters;
|
||||
final query = params['q'] ?? '';
|
||||
final type = _typeFromParam(params['type']);
|
||||
final status = _statusFromParam(params['status']);
|
||||
@@ -231,8 +246,8 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
),
|
||||
],
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 260,
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 260),
|
||||
child: ShadInput(
|
||||
controller: _searchController,
|
||||
focusNode: _searchFocus,
|
||||
@@ -241,8 +256,8 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
onSubmitted: (_) => _applyFilters(),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 200,
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 200),
|
||||
child: ShadSelect<CustomerTypeFilter>(
|
||||
key: ValueKey(_controller.typeFilter),
|
||||
initialValue: _controller.typeFilter,
|
||||
@@ -262,8 +277,8 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 200,
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 200),
|
||||
child: ShadSelect<CustomerStatusFilter>(
|
||||
key: ValueKey(_controller.statusFilter),
|
||||
initialValue: _controller.statusFilter,
|
||||
@@ -286,21 +301,27 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
],
|
||||
),
|
||||
child: ShadCard(
|
||||
title: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('고객사 목록', style: theme.textTheme.h3),
|
||||
Text('$totalCount건', style: theme.textTheme.muted),
|
||||
],
|
||||
title: ResponsiveStackedRow(
|
||||
leading: Text('고객사 목록', style: theme.textTheme.h3),
|
||||
trailing: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text('$totalCount건', style: theme.textTheme.muted),
|
||||
),
|
||||
),
|
||||
footer: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'페이지 $currentPage / $totalPages',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
Row(
|
||||
footer: ResponsiveStackedRow(
|
||||
gap: 8,
|
||||
breakpoint: 420,
|
||||
leading: Text(
|
||||
'페이지 $currentPage / $totalPages',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
trailing: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.end,
|
||||
runAlignment: WrapAlignment.end,
|
||||
children: [
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
@@ -309,7 +330,6 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
: () => _goToPage(1),
|
||||
child: const Text('처음'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isLoading || currentPage <= 1
|
||||
@@ -317,7 +337,6 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
: () => _goToPage(currentPage - 1),
|
||||
child: const Text('이전'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isLoading || !hasNext
|
||||
@@ -325,18 +344,17 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
: () => _goToPage(currentPage + 1),
|
||||
child: const Text('다음'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed:
|
||||
_controller.isLoading || currentPage >= totalPages
|
||||
? null
|
||||
: () => _goToPage(totalPages),
|
||||
? null
|
||||
: () => _goToPage(totalPages),
|
||||
child: const Text('마지막'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: _controller.isLoading
|
||||
? const Padding(
|
||||
@@ -703,8 +721,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
return ShadButton.ghost(
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () =>
|
||||
Navigator.of(context, rootNavigator: true).pop(false),
|
||||
: () => Navigator.of(context, rootNavigator: true).pop(false),
|
||||
child: const Text('취소'),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:superport_v2/widgets/app_layout.dart';
|
||||
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
||||
import 'package:superport_v2/widgets/components/superport_dialog.dart';
|
||||
import 'package:superport_v2/widgets/components/superport_table.dart';
|
||||
import 'package:superport_v2/widgets/components/responsive_section.dart';
|
||||
|
||||
import '../../../../../core/config/environment.dart';
|
||||
import '../../../../../widgets/spec_page.dart';
|
||||
@@ -208,8 +209,8 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
),
|
||||
],
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 260,
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 260),
|
||||
child: ShadInput(
|
||||
controller: _searchController,
|
||||
focusNode: _searchFocus,
|
||||
@@ -218,8 +219,8 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
onSubmitted: (_) => _applyFilters(),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 220,
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 220),
|
||||
child: ShadSelect<int?>(
|
||||
key: ValueKey(_controller.vendorFilter),
|
||||
initialValue: _controller.vendorFilter,
|
||||
@@ -248,8 +249,8 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 220,
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 220),
|
||||
child: ShadSelect<int?>(
|
||||
key: ValueKey(_controller.uomFilter),
|
||||
initialValue: _controller.uomFilter,
|
||||
@@ -277,8 +278,8 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 200,
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 200),
|
||||
child: ShadSelect<ProductStatusFilter>(
|
||||
key: ValueKey(_controller.statusFilter),
|
||||
initialValue: _controller.statusFilter,
|
||||
@@ -301,21 +302,27 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
],
|
||||
),
|
||||
child: ShadCard(
|
||||
title: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('제품 목록', style: theme.textTheme.h3),
|
||||
Text('$totalCount건', style: theme.textTheme.muted),
|
||||
],
|
||||
title: ResponsiveStackedRow(
|
||||
leading: Text('제품 목록', style: theme.textTheme.h3),
|
||||
trailing: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text('$totalCount건', style: theme.textTheme.muted),
|
||||
),
|
||||
),
|
||||
footer: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'페이지 $currentPage / $totalPages',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
Row(
|
||||
footer: ResponsiveStackedRow(
|
||||
gap: 8,
|
||||
breakpoint: 420,
|
||||
leading: Text(
|
||||
'페이지 $currentPage / $totalPages',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
trailing: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.end,
|
||||
runAlignment: WrapAlignment.end,
|
||||
children: [
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
@@ -324,7 +331,6 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
: () => _goToPage(1),
|
||||
child: const Text('처음'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isLoading || currentPage <= 1
|
||||
@@ -332,7 +338,6 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
: () => _goToPage(currentPage - 1),
|
||||
child: const Text('이전'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isLoading || !hasNext
|
||||
@@ -340,7 +345,6 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
: () => _goToPage(currentPage + 1),
|
||||
child: const Text('다음'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed:
|
||||
@@ -351,7 +355,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: _controller.isLoading
|
||||
? const Padding(
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:superport_v2/widgets/app_layout.dart';
|
||||
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
||||
import 'package:superport_v2/widgets/components/superport_dialog.dart';
|
||||
import 'package:superport_v2/widgets/components/superport_table.dart';
|
||||
import 'package:superport_v2/widgets/components/responsive_section.dart';
|
||||
|
||||
import '../../../../../core/config/environment.dart';
|
||||
import '../../../../../widgets/spec_page.dart';
|
||||
@@ -198,8 +199,8 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
||||
),
|
||||
],
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 280,
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
child: ShadInput(
|
||||
controller: _searchController,
|
||||
focusNode: _searchFocusNode,
|
||||
@@ -208,8 +209,8 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
||||
onSubmitted: (_) => _applyFilters(),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 220,
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 220),
|
||||
child: ShadSelect<VendorStatusFilter>(
|
||||
key: ValueKey(_controller.statusFilter),
|
||||
initialValue: _controller.statusFilter,
|
||||
@@ -233,21 +234,27 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
||||
],
|
||||
),
|
||||
child: ShadCard(
|
||||
title: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('벤더 목록', style: theme.textTheme.h3),
|
||||
Text('$totalCount건', style: theme.textTheme.muted),
|
||||
],
|
||||
title: ResponsiveStackedRow(
|
||||
leading: Text('벤더 목록', style: theme.textTheme.h3),
|
||||
trailing: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text('$totalCount건', style: theme.textTheme.muted),
|
||||
),
|
||||
),
|
||||
footer: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'페이지 $currentPage / $totalPages',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
Row(
|
||||
footer: ResponsiveStackedRow(
|
||||
gap: 8,
|
||||
breakpoint: 420,
|
||||
leading: Text(
|
||||
'페이지 $currentPage / $totalPages',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
trailing: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.end,
|
||||
runAlignment: WrapAlignment.end,
|
||||
children: [
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
@@ -256,7 +263,6 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
||||
: () => _goToPage(1),
|
||||
child: const Text('처음'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isLoading || currentPage <= 1
|
||||
@@ -264,7 +270,6 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
||||
: () => _goToPage(currentPage - 1),
|
||||
child: const Text('이전'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isLoading || !hasNext
|
||||
@@ -272,17 +277,17 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
||||
: () => _goToPage(currentPage + 1),
|
||||
child: const Text('다음'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isLoading || currentPage >= totalPages
|
||||
onPressed:
|
||||
_controller.isLoading || currentPage >= totalPages
|
||||
? null
|
||||
: () => _goToPage(totalPages),
|
||||
child: const Text('마지막'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: _controller.isLoading
|
||||
? const Padding(
|
||||
@@ -510,8 +515,7 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
||||
return ShadButton.ghost(
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () =>
|
||||
Navigator.of(context, rootNavigator: true).pop(false),
|
||||
: () => Navigator.of(context, rootNavigator: true).pop(false),
|
||||
child: const Text('취소'),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:superport_v2/widgets/app_layout.dart';
|
||||
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
||||
import 'package:superport_v2/widgets/components/superport_dialog.dart';
|
||||
import 'package:superport_v2/widgets/components/superport_table.dart';
|
||||
import 'package:superport_v2/widgets/components/responsive_section.dart';
|
||||
import 'package:superport_v2/features/util/postal_search/presentation/models/postal_search_result.dart';
|
||||
import 'package:superport_v2/features/util/postal_search/presentation/widgets/postal_search_dialog.dart';
|
||||
|
||||
@@ -99,7 +100,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
final FocusNode _searchFocus = FocusNode();
|
||||
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm');
|
||||
String? _lastError;
|
||||
bool _routeApplied = false;
|
||||
String? _lastAppliedRoute;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -112,9 +113,14 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_routeApplied) {
|
||||
_routeApplied = true;
|
||||
_applyRouteParameters();
|
||||
_applyRouteIfNeeded();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _WarehouseEnabledPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.routeUri != oldWidget.routeUri) {
|
||||
_applyRouteIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,8 +211,8 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
),
|
||||
],
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 260,
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 260),
|
||||
child: ShadInput(
|
||||
controller: _searchController,
|
||||
focusNode: _searchFocus,
|
||||
@@ -215,8 +221,8 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
onSubmitted: (_) => _applyFilters(),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 200,
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 200),
|
||||
child: ShadSelect<WarehouseStatusFilter>(
|
||||
key: ValueKey(_controller.statusFilter),
|
||||
initialValue: _controller.statusFilter,
|
||||
@@ -240,21 +246,27 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
],
|
||||
),
|
||||
child: ShadCard(
|
||||
title: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('창고 목록', style: theme.textTheme.h3),
|
||||
Text('$totalCount건', style: theme.textTheme.muted),
|
||||
],
|
||||
title: ResponsiveStackedRow(
|
||||
leading: Text('창고 목록', style: theme.textTheme.h3),
|
||||
trailing: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text('$totalCount건', style: theme.textTheme.muted),
|
||||
),
|
||||
),
|
||||
footer: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'페이지 $currentPage / $totalPages',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
Row(
|
||||
footer: ResponsiveStackedRow(
|
||||
gap: 8,
|
||||
breakpoint: 420,
|
||||
leading: Text(
|
||||
'페이지 $currentPage / $totalPages',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
trailing: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.end,
|
||||
runAlignment: WrapAlignment.end,
|
||||
children: [
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
@@ -263,7 +275,6 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
: () => _goToPage(1),
|
||||
child: const Text('처음'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isLoading || currentPage <= 1
|
||||
@@ -271,7 +282,6 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
: () => _goToPage(currentPage - 1),
|
||||
child: const Text('이전'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isLoading || !hasNext
|
||||
@@ -279,18 +289,17 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
: () => _goToPage(currentPage + 1),
|
||||
child: const Text('다음'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed:
|
||||
_controller.isLoading || currentPage >= totalPages
|
||||
? null
|
||||
: () => _goToPage(totalPages),
|
||||
? null
|
||||
: () => _goToPage(totalPages),
|
||||
child: const Text('마지막'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: _controller.isLoading
|
||||
? const Padding(
|
||||
@@ -323,6 +332,15 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _applyRouteIfNeeded() {
|
||||
final current = widget.routeUri.toString();
|
||||
if (_lastAppliedRoute == current) {
|
||||
return;
|
||||
}
|
||||
_lastAppliedRoute = current;
|
||||
_applyRouteParameters();
|
||||
}
|
||||
|
||||
void _applyFilters() {
|
||||
final keyword = _searchController.text.trim();
|
||||
_controller.updateQuery(keyword);
|
||||
@@ -576,8 +594,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
return ShadButton.ghost(
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () =>
|
||||
Navigator.of(context, rootNavigator: true).pop(false),
|
||||
: () => Navigator.of(context, rootNavigator: true).pop(false),
|
||||
child: const Text('취소'),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import 'responsive_section.dart';
|
||||
|
||||
/// 페이지 상단 타이틀/설명/액션을 일관되게 출력하는 헤더.
|
||||
class PageHeader extends StatelessWidget {
|
||||
const PageHeader({
|
||||
@@ -22,27 +24,52 @@ class PageHeader extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (leading != null) ...[leading!, const SizedBox(width: 16)],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.h2),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(subtitle!, style: theme.textTheme.muted),
|
||||
final actionWidgets = actions ?? const <Widget>[];
|
||||
|
||||
final trailingSection = (actionWidgets.isEmpty && trailing == null)
|
||||
? const SizedBox.shrink()
|
||||
: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (actionWidgets.isNotEmpty)
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
alignment: WrapAlignment.end,
|
||||
children: actionWidgets,
|
||||
),
|
||||
if (actionWidgets.isNotEmpty && trailing != null)
|
||||
const SizedBox(height: 12),
|
||||
if (trailing != null) trailing!,
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return ResponsiveStackedRow(
|
||||
breakpoint: 640,
|
||||
gap: 12,
|
||||
leading: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (leading != null) ...[leading!, const SizedBox(width: 16)],
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.h2),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(subtitle!, style: theme.textTheme.muted),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (actions != null && actions!.isNotEmpty) ...[
|
||||
Wrap(spacing: 12, runSpacing: 12, children: actions!),
|
||||
],
|
||||
if (trailing != null) ...[const SizedBox(width: 16), trailing!],
|
||||
],
|
||||
),
|
||||
trailing: trailingSection,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
54
lib/widgets/components/responsive_section.dart
Normal file
54
lib/widgets/components/responsive_section.dart
Normal 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),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -264,5 +264,47 @@ void main() {
|
||||
expect(find.text('C-100'), findsOneWidget);
|
||||
verify(() => repository.create(any())).called(1);
|
||||
});
|
||||
|
||||
testWidgets('좁은 폭에서도 오버플로 없이 렌더링', (tester) async {
|
||||
when(
|
||||
() => repository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
isPartner: any(named: 'isPartner'),
|
||||
isGeneral: any(named: 'isGeneral'),
|
||||
isActive: any(named: 'isActive'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => PaginatedResult<Customer>(
|
||||
items: [
|
||||
Customer(
|
||||
id: 1,
|
||||
customerCode: 'C-SMALL',
|
||||
customerName: '좁은 화면 고객',
|
||||
isPartner: false,
|
||||
isGeneral: true,
|
||||
),
|
||||
],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildApp(
|
||||
Center(
|
||||
child: SizedBox(
|
||||
width: 260,
|
||||
child: CustomerPage(routeUri: Uri(path: '/masters/customers')),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -312,5 +312,51 @@ void main() {
|
||||
expect(find.text('NP-001'), findsOneWidget);
|
||||
verify(() => productRepository.create(any())).called(1);
|
||||
});
|
||||
|
||||
testWidgets('좁은 폭에서도 오버플로 없이 렌더링', (tester) async {
|
||||
when(
|
||||
() => productRepository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
vendorId: any(named: 'vendorId'),
|
||||
uomId: any(named: 'uomId'),
|
||||
isActive: any(named: 'isActive'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => PaginatedResult<Product>(
|
||||
items: [
|
||||
Product(
|
||||
id: 1,
|
||||
productCode: 'P-SMALL',
|
||||
productName: '좁은 화면 제품',
|
||||
vendor: ProductVendor(
|
||||
id: 1,
|
||||
vendorCode: 'V-001',
|
||||
vendorName: '슈퍼벤더',
|
||||
),
|
||||
uom: ProductUom(id: 5, uomName: 'EA'),
|
||||
),
|
||||
],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildApp(
|
||||
Center(
|
||||
child: SizedBox(
|
||||
width: 260,
|
||||
child: ProductPage(routeUri: Uri(path: '/masters/products')),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -216,4 +216,40 @@ void main() {
|
||||
expect(find.text('NV-001'), findsOneWidget);
|
||||
verify(() => repository.create(any())).called(1);
|
||||
});
|
||||
|
||||
testWidgets('좁은 폭에서도 오버플로 없이 렌더링', (tester) async {
|
||||
dotenv.testLoad(fileInput: 'FEATURE_VENDORS_ENABLED=true\n');
|
||||
final repository = _MockVendorRepository();
|
||||
GetIt.I.registerLazySingleton<VendorRepository>(() => repository);
|
||||
|
||||
when(
|
||||
() => repository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
isActive: any(named: 'isActive'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => PaginatedResult<Vendor>(
|
||||
items: [Vendor(id: 1, vendorCode: 'V-SMALL', vendorName: '좁은 화면 벤더')],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildApp(
|
||||
Center(
|
||||
child: SizedBox(
|
||||
width: 260,
|
||||
child: VendorPage(routeUri: Uri(path: '/masters/vendors')),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -247,5 +247,45 @@ void main() {
|
||||
expect(find.text('WH-100'), findsOneWidget);
|
||||
verify(() => repository.create(any())).called(1);
|
||||
});
|
||||
|
||||
testWidgets('좁은 폭에서도 오버플로 없이 렌더링', (tester) async {
|
||||
when(
|
||||
() => repository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
isActive: any(named: 'isActive'),
|
||||
includeZipcode: any(named: 'includeZipcode'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => PaginatedResult<Warehouse>(
|
||||
items: [
|
||||
Warehouse(
|
||||
id: 1,
|
||||
warehouseCode: 'WH-SMALL',
|
||||
warehouseName: '좁은 화면 창고',
|
||||
zipcode: WarehouseZipcode(zipcode: '06000'),
|
||||
),
|
||||
],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildApp(
|
||||
Center(
|
||||
child: SizedBox(
|
||||
width: 260,
|
||||
child: WarehousePage(routeUri: Uri(path: '/masters/warehouses')),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user