API v4 계약 반영하고 보고서·입출고 화면 실연동 강화

This commit is contained in:
JiWoong Sul
2025-10-16 14:57:07 +09:00
parent 7e0f7b1c55
commit d5c99627db
34 changed files with 1767 additions and 327 deletions

View File

@@ -7,8 +7,11 @@ import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:superport_v2/core/constants/app_sections.dart';
import 'package:superport_v2/core/network/failure.dart';
import 'package:superport_v2/core/services/file_saver.dart';
import 'package:superport_v2/features/inventory/lookups/domain/entities/lookup_item.dart';
import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
import 'package:superport_v2/features/inventory/shared/widgets/employee_autocomplete_field.dart';
import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart';
import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart';
import 'package:superport_v2/features/reporting/domain/entities/report_download_result.dart';
@@ -20,7 +23,6 @@ import 'package:superport_v2/widgets/components/empty_state.dart';
import 'package:superport_v2/widgets/components/feedback.dart';
import 'package:superport_v2/widgets/components/filter_bar.dart';
import 'package:superport_v2/widgets/components/superport_date_picker.dart';
import 'package:superport_v2/core/network/failure.dart';
/// 보고서 다운로드 화면 루트 위젯.
class ReportingPage extends StatefulWidget {
@@ -36,7 +38,6 @@ class _ReportingPageState extends State<ReportingPage> {
ReportingRepository? _reportingRepository;
InventoryLookupRepository? _lookupRepository;
final intl.DateFormat _dateFormat = intl.DateFormat('yyyy.MM.dd');
final Map<ReportTypeFilter, LookupItem> _transactionTypeLookup = {};
final Map<ReportStatusFilter, LookupItem> _transactionStatusLookup = {};
final Map<ReportStatusFilter, LookupItem> _approvalStatusLookup = {};
bool _isLoadingLookups = false;
@@ -45,12 +46,9 @@ class _ReportingPageState extends State<ReportingPage> {
String? _exportError;
ReportDownloadResult? _lastResult;
ReportExportFormat? _lastFormat;
static const Map<ReportTypeFilter, List<String>> _transactionTypeKeywords = {
ReportTypeFilter.inbound: ['입고', 'inbound'],
ReportTypeFilter.outbound: ['출고', 'outbound'],
ReportTypeFilter.rental: ['대여', 'rent', 'rental'],
};
final TextEditingController _requesterController = TextEditingController();
InventoryEmployeeSuggestion? _appliedRequester;
InventoryEmployeeSuggestion? _pendingRequester;
static const Map<ReportStatusFilter, List<String>>
_transactionStatusKeywords = {
@@ -105,6 +103,12 @@ class _ReportingPageState extends State<ReportingPage> {
_loadWarehouses();
}
@override
void dispose() {
_requesterController.dispose();
super.dispose();
}
/// 활성 창고 목록을 불러와 드롭다운 옵션을 준비한다.
Future<void> _loadWarehouses() async {
setState(() {
@@ -173,16 +177,12 @@ class _ReportingPageState extends State<ReportingPage> {
_lookupError = null;
});
try {
final transactionTypes = await repository.fetchTransactionTypes();
final transactionStatuses = await repository.fetchTransactionStatuses();
final approvalStatuses = await repository.fetchApprovalStatuses();
if (!mounted) {
return;
}
setState(() {
_transactionTypeLookup
..clear()
..addAll(_mapTransactionTypes(transactionTypes));
_transactionStatusLookup
..clear()
..addAll(
@@ -228,6 +228,9 @@ class _ReportingPageState extends State<ReportingPage> {
_pendingStatus = ReportStatusFilter.all;
_appliedWarehouse = WarehouseFilterOption.all;
_pendingWarehouse = WarehouseFilterOption.all;
_appliedRequester = null;
_pendingRequester = null;
_requesterController.clear();
});
}
@@ -238,6 +241,7 @@ class _ReportingPageState extends State<ReportingPage> {
_appliedType = _pendingType;
_appliedStatus = _pendingStatus;
_appliedWarehouse = _pendingWarehouse;
_appliedRequester = _pendingRequester;
});
}
@@ -249,7 +253,8 @@ class _ReportingPageState extends State<ReportingPage> {
return _appliedDateRange != null ||
_appliedType != ReportTypeFilter.all ||
_appliedStatus != ReportStatusFilter.all ||
_appliedWarehouse != WarehouseFilterOption.all;
_appliedWarehouse != WarehouseFilterOption.all ||
_appliedRequester != null;
}
bool get _hasAppliedFilters => _hasCustomFilters;
@@ -258,7 +263,8 @@ class _ReportingPageState extends State<ReportingPage> {
!_isSameRange(_pendingDateRange, _appliedDateRange) ||
_pendingType != _appliedType ||
_pendingStatus != _appliedStatus ||
_pendingWarehouse != _appliedWarehouse;
_pendingWarehouse != _appliedWarehouse ||
!_isSameRequester(_pendingRequester, _appliedRequester);
bool _isSameRange(DateTimeRange? a, DateTimeRange? b) {
if (identical(a, b)) {
@@ -270,6 +276,19 @@ class _ReportingPageState extends State<ReportingPage> {
return a.start == b.start && a.end == b.end;
}
bool _isSameRequester(
InventoryEmployeeSuggestion? a,
InventoryEmployeeSuggestion? b,
) {
if (identical(a, b)) {
return true;
}
if (a == null || b == null) {
return a == b;
}
return a.id == b.id;
}
WarehouseFilterOption _resolveWarehouseOption(
WarehouseFilterOption target,
List<WarehouseFilterOption> options,
@@ -282,19 +301,6 @@ class _ReportingPageState extends State<ReportingPage> {
return options.first;
}
Map<ReportTypeFilter, LookupItem> _mapTransactionTypes(
List<LookupItem> items,
) {
final result = <ReportTypeFilter, LookupItem>{};
for (final entry in _transactionTypeKeywords.entries) {
final matched = _matchLookup(items, entry.value);
if (matched != null) {
result[entry.key] = matched;
}
}
return result;
}
Map<ReportStatusFilter, LookupItem> _mapStatusByKeyword(
List<LookupItem> items,
Map<ReportStatusFilter, List<String>> keywords,
@@ -326,25 +332,20 @@ class _ReportingPageState extends State<ReportingPage> {
return null;
}
int? _resolveTransactionTypeId() {
if (_appliedType == ReportTypeFilter.all ||
_appliedType == ReportTypeFilter.approval) {
return null;
}
final lookup = _transactionTypeLookup[_appliedType];
return lookup?.id;
}
int? _resolveStatusId() {
int? _resolveTransactionStatusId() {
if (_appliedStatus == ReportStatusFilter.all) {
return null;
}
if (_appliedType == ReportTypeFilter.approval) {
return _approvalStatusLookup[_appliedStatus]?.id;
}
return _transactionStatusLookup[_appliedStatus]?.id;
}
int? _resolveApprovalStatusId() {
if (_appliedStatus == ReportStatusFilter.all) {
return null;
}
return _approvalStatusLookup[_appliedStatus]?.id;
}
String _dateRangeLabel(DateTimeRange? range) {
if (range == null) {
return '기간 선택';
@@ -354,6 +355,13 @@ class _ReportingPageState extends State<ReportingPage> {
String _formatDate(DateTime value) => _dateFormat.format(value);
String _requesterLabel(InventoryEmployeeSuggestion? suggestion) {
if (suggestion == null) {
return '전체 상신자';
}
return '${suggestion.name} (${suggestion.employeeNo})';
}
Future<void> _handleExport(ReportExportFormat format) async {
if (_isExporting) {
return;
@@ -376,9 +384,9 @@ class _ReportingPageState extends State<ReportingPage> {
from: range.start,
to: range.end,
format: format,
transactionTypeId: _resolveTransactionTypeId(),
statusId: _resolveStatusId(),
warehouseId: _appliedWarehouse.id,
transactionStatusId: _resolveTransactionStatusId(),
approvalStatusId: _resolveApprovalStatusId(),
requestedById: _appliedRequester?.id,
);
try {
final result = _appliedType == ReportTypeFilter.approval
@@ -394,7 +402,7 @@ class _ReportingPageState extends State<ReportingPage> {
if (result.hasDownloadUrl) {
SuperportToast.success(context, '다운로드 링크가 준비되었습니다.');
} else if (result.hasBytes) {
SuperportToast.success(context, '보고서 파일이 준비되었습니다. 저장 기능은 추후 제공 예정입니다.');
await _saveBinaryResult(result, format);
} else {
SuperportToast.info(context, '다운로드 결과를 확인했지만 추가 처리 항목이 없습니다.');
}
@@ -419,6 +427,51 @@ class _ReportingPageState extends State<ReportingPage> {
}
}
Future<void> _saveBinaryResult(
ReportDownloadResult result,
ReportExportFormat format,
) async {
final bytes = result.bytes;
if (bytes == null || bytes.isEmpty) {
return;
}
final filename = result.filename ?? 'report.${format.apiValue}';
final mimeType = result.mimeType ?? _mimeTypeForFormat(format);
try {
await saveFileBytes(bytes: bytes, filename: filename, mimeType: mimeType);
if (!mounted) {
return;
}
SuperportToast.success(context, '보고서 파일 다운로드가 시작되었습니다.');
} on UnsupportedError {
if (!mounted) {
return;
}
SuperportToast.info(
context,
'현재 환경에서는 자동 저장을 지원하지 않습니다. 다운로드 링크 요청 기능을 이용하세요.',
);
} catch (_) {
if (!mounted) {
return;
}
SuperportToast.error(context, '파일을 저장하는 중 문제가 발생했습니다. 잠시 후 다시 시도하세요.');
}
}
void _notifyPdfUnavailable() {
SuperportToast.info(context, 'PDF 다운로드는 현재 지원되지 않습니다.');
}
String _mimeTypeForFormat(ReportExportFormat format) {
switch (format) {
case ReportExportFormat.xlsx:
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
case ReportExportFormat.pdf:
return 'application/pdf';
}
}
Future<void> _launchDownloadUrl(Uri url) async {
try {
final opened = await launchUrl(url, mode: LaunchMode.externalApplication);
@@ -476,10 +529,7 @@ class _ReportingPageState extends State<ReportingPage> {
if (result.downloadUrl != null)
_SummaryRow(label: '다운로드 URL', value: result.downloadUrl!.toString()),
if (result.hasBytes && (result.downloadUrl == null))
const _SummaryRow(
label: '상태',
value: '바이너리 응답이 준비되었습니다. 저장 기능은 추후 제공 예정입니다.',
),
const _SummaryRow(label: '상태', value: '바이너리 응답을 받아 자동 다운로드를 실행했습니다.'),
];
return Column(
@@ -530,9 +580,7 @@ class _ReportingPageState extends State<ReportingPage> {
child: const Text('XLSX 다운로드'),
),
ShadButton.outline(
onPressed: _canExport
? () => _handleExport(ReportExportFormat.pdf)
: null,
onPressed: _canExport ? _notifyPdfUnavailable : null,
leading: const Icon(lucide.LucideIcons.fileText, size: 16),
child: const Text('PDF 다운로드'),
),
@@ -648,6 +696,40 @@ class _ReportingPageState extends State<ReportingPage> {
],
),
),
SizedBox(
width: 260,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'상신자',
style: theme.textTheme.small.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
InventoryEmployeeAutocompleteField(
key: ValueKey(_pendingRequester?.id ?? 'none'),
controller: _requesterController,
initialSuggestion: _pendingRequester,
placeholder: '상신자 이름 또는 사번 검색',
onSuggestionSelected: (suggestion) {
setState(() {
_pendingRequester = suggestion;
});
},
onChanged: () {
if (_requesterController.text.trim().isEmpty &&
_pendingRequester != null) {
setState(() {
_pendingRequester = null;
});
}
},
),
],
),
),
],
),
child: Column(
@@ -712,6 +794,10 @@ class _ReportingPageState extends State<ReportingPage> {
_SummaryRow(label: '유형', value: _appliedType.label),
_SummaryRow(label: '창고', value: _appliedWarehouse.label),
_SummaryRow(label: '상태', value: _appliedStatus.label),
_SummaryRow(
label: '상신자',
value: _requesterLabel(_appliedRequester),
),
if (!_canExport)
Padding(
padding: const EdgeInsets.only(top: 12),