재고 상세 다이얼로그화 및 마스터 레이아웃 개선
This commit is contained in:
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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/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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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/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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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/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('취소'),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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('취소'),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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('취소'),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user