feat(dialog): 상세 팝업 SuperportDetailDialog 통합

- SuperportDetailDialog 위젯과 showSuperportDetailDialog 헬퍼를 추가하고 metadata/섹션 패턴을 표준화

- 결재/재고/마스터 각 상세 다이얼로그를 dialogs 디렉터리에 신설하고 기존 페이지를 신규 팝업으로 전환

- SuperportTable 행 선택과 우편번호 검색 다이얼로그 onRowTap 보정을 통해 헤더 오프셋 버그를 제거

- 상세 다이얼로그 및 트랜잭션/상세 뷰 전용 위젯 테스트와 tester_extensions 유틸을 추가하여 회귀를 방지

- detail_dialog_unification_plan.md로 작업 배경과 필드 통합 계획을 문서화
This commit is contained in:
JiWoong Sul
2025-11-07 19:02:43 +09:00
parent 1f78171294
commit 2f8b529506
64 changed files with 13721 additions and 7545 deletions

View File

@@ -44,6 +44,10 @@ import '../widgets/rental_detail_view.dart';
const String _rentalTransactionTypeRent = '대여';
const String _rentalTransactionTypeReturn = '반납';
class _RentalDetailSections {
static const lines = 'lines';
}
/// 대여/반납 목록과 등록 모달을 관리하는 페이지.
class RentalPage extends StatefulWidget {
const RentalPage({super.key, required this.routeUri});
@@ -597,7 +601,11 @@ class _RentalPageState extends State<RentalPage> {
RentalTableSpec.rowSpanHeight,
),
onRowTap: (rowIndex) {
final record = visibleRecords[rowIndex];
if (rowIndex <= 0 ||
rowIndex > visibleRecords.length) {
return;
}
final record = visibleRecords[rowIndex - 1];
_selectRecord(record, openDetail: true);
},
);
@@ -829,18 +837,116 @@ class _RentalPageState extends State<RentalPage> {
await showInventoryTransactionDetailDialog<void>(
context: context,
title: '대여 상세',
transactionNumber: record.transactionNumber,
body: RentalDetailView(
record: record,
dateFormatter: _dateFormatter,
currencyFormatter: _currencyFormatter,
transitionsEnabled: _transitionsEnabled,
),
description: '대여 고객사와 라인 품목을 확인하세요.',
summary: _buildRentalDetailSummary(record),
summaryBadges: _buildRentalDetailBadges(record),
metadata: _buildRentalDetailMetadata(record),
sections: [
SuperportDetailDialogSection(
id: _RentalDetailSections.lines,
label: '라인 품목',
icon: lucide.LucideIcons.listChecks,
builder: (_) => RentalDetailView(
record: record,
currencyFormatter: _currencyFormatter,
transitionsEnabled: _transitionsEnabled,
),
),
],
initialSectionId: _RentalDetailSections.lines,
actions: _buildDetailActions(record),
constraints: const BoxConstraints(maxWidth: 920),
);
}
Widget _buildRentalDetailSummary(RentalRecord record) {
final theme = ShadTheme.of(context);
final processedAt = _dateFormatter.format(record.processedAt);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(record.transactionNumber, style: theme.textTheme.h4),
const SizedBox(height: 4),
Text(
'$processedAt · ${record.rentalType}',
style: theme.textTheme.muted,
),
],
);
}
List<Widget> _buildRentalDetailBadges(RentalRecord record) {
return [
ShadBadge(child: Text(record.status)),
ShadBadge.outline(child: Text(record.transactionType)),
];
}
List<SuperportDetailMetadata> _buildRentalDetailMetadata(
RentalRecord record,
) {
return [
SuperportDetailMetadata.text(
label: '트랜잭션번호',
value: record.transactionNumber,
),
SuperportDetailMetadata.text(
label: '처리일자',
value: _dateFormatter.format(record.processedAt),
),
SuperportDetailMetadata.text(
label: '트랜잭션 유형',
value: record.transactionType,
),
SuperportDetailMetadata.text(label: '대여 구분', value: record.rentalType),
SuperportDetailMetadata.text(label: '상태', value: record.status),
SuperportDetailMetadata.text(label: '작성자', value: record.writer),
SuperportDetailMetadata.text(label: '창고', value: record.warehouse),
SuperportDetailMetadata.text(
label: '창고 코드',
value: _dashIfEmpty(record.warehouseCode),
),
SuperportDetailMetadata.text(
label: '창고 우편번호',
value: _dashIfEmpty(record.warehouseZipcode),
),
SuperportDetailMetadata.text(
label: '창고 주소',
value: _dashIfEmpty(record.warehouseAddress),
),
SuperportDetailMetadata.text(
label: '고객 수',
value: '${record.customerCount}',
),
SuperportDetailMetadata.text(label: '품목 수', value: '${record.itemCount}'),
SuperportDetailMetadata.text(
label: '총 수량',
value: '${record.totalQuantity}',
),
SuperportDetailMetadata.text(
label: '총 금액',
value: _currencyFormatter.format(record.totalAmount),
),
SuperportDetailMetadata.text(
label: '반납 예정일',
value: record.returnDueDate == null
? '-'
: _dateFormatter.format(record.returnDueDate!),
),
SuperportDetailMetadata.text(
label: '비고',
value: _dashIfEmpty(record.remark),
),
];
}
String _dashIfEmpty(String? value) {
if (value == null) {
return '-';
}
final trimmed = value.trim();
return trimmed.isEmpty ? '-' : trimmed;
}
List<Widget> _buildDetailActions(RentalRecord record) {
final isProcessing = _isProcessing(record.id) || _isLoading;
final actions = <Widget>[];

View File

@@ -9,13 +9,11 @@ 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;
@@ -31,36 +29,6 @@ class RentalDetailView extends StatelessWidget {
),
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(
@@ -119,36 +87,3 @@ class RentalDetailView 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 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),
],
),
);
}
}