API v4 계약 반영하고 보고서·입출고 화면 실연동 강화
This commit is contained in:
@@ -49,10 +49,12 @@ class ReportingRepositoryRemote implements ReportingRepository {
|
||||
'from': request.from.toIso8601String(),
|
||||
'to': request.to.toIso8601String(),
|
||||
'format': request.format.apiValue,
|
||||
if (request.transactionTypeId != null)
|
||||
'type_id': request.transactionTypeId,
|
||||
if (request.statusId != null) 'status_id': request.statusId,
|
||||
if (request.warehouseId != null) 'warehouse_id': request.warehouseId,
|
||||
if (request.transactionStatusId != null)
|
||||
'transaction_status_id': request.transactionStatusId,
|
||||
if (request.approvalStatusId != null)
|
||||
'approval_status_id': request.approvalStatusId,
|
||||
if (request.requestedById != null)
|
||||
'requested_by_id': request.requestedById,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ class ReportExportRequest {
|
||||
required this.from,
|
||||
required this.to,
|
||||
required this.format,
|
||||
this.transactionTypeId,
|
||||
this.statusId,
|
||||
this.warehouseId,
|
||||
this.transactionStatusId,
|
||||
this.approvalStatusId,
|
||||
this.requestedById,
|
||||
});
|
||||
|
||||
/// 조회 시작 일자.
|
||||
@@ -20,12 +20,12 @@ class ReportExportRequest {
|
||||
/// 내보내기 파일 형식.
|
||||
final ReportExportFormat format;
|
||||
|
||||
/// 재고 트랜잭션 유형 식별자.
|
||||
final int? transactionTypeId;
|
||||
/// 트랜잭션 상태 식별자.
|
||||
final int? transactionStatusId;
|
||||
|
||||
/// 결재 상태 식별자.
|
||||
final int? statusId;
|
||||
final int? approvalStatusId;
|
||||
|
||||
/// 창고 식별자.
|
||||
final int? warehouseId;
|
||||
/// 상신자(요청자) 식별자.
|
||||
final int? requestedById;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user