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

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

View File

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

View File

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