feat: 재고 상태 전이 플래그 적용 및 실패 메시지 정비

This commit is contained in:
JiWoong Sul
2025-10-14 18:06:40 +09:00
parent 9f61b305d4
commit c072eb1328
9 changed files with 5069 additions and 1287 deletions

View File

@@ -1,10 +1,19 @@
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../core/constants/app_sections.dart';
import '../../../../core/network/failure.dart';
import '../../../../core/permissions/permission_manager.dart';
import '../../../../core/permissions/permission_resources.dart';
import '../../../masters/group/domain/entities/group.dart';
import '../../../masters/group/domain/repositories/group_repository.dart';
import '../../../masters/group_permission/application/permission_synchronizer.dart';
import '../../../masters/group_permission/domain/repositories/group_permission_repository.dart';
/// Superport 로그인 화면. 간단한 유효성 검증 후 대시보드로 이동한다.
class LoginPage extends StatefulWidget {
@@ -58,6 +67,24 @@ class _LoginPageState extends State<LoginPage> {
}
if (!mounted) return;
try {
await _synchronizePermissions();
} catch (error) {
if (!mounted) return;
final failure = Failure.from(error);
final description = failure.describe();
final message = description.isEmpty
? '권한 정보를 불러오지 못했습니다. 잠시 후 다시 시도하세요.'
: description;
setState(() {
errorMessage = message;
isLoading = false;
});
return;
}
if (!mounted) return;
setState(() => isLoading = false);
context.go(dashboardRoutePath);
}
@@ -222,6 +249,13 @@ class _LoginPageState extends State<LoginPage> {
],
),
),
if (kDebugMode) ...[
const SizedBox(height: 12),
ShadButton.ghost(
onPressed: isLoading ? null : _handleTestLogin,
child: const Text('테스트 로그인'),
),
],
],
),
);
@@ -250,4 +284,73 @@ class _LoginPageState extends State<LoginPage> {
],
);
}
/// 디버그 모드에서 모든 권한을 부여하고 즉시 대시보드로 이동한다.
void _handleTestLogin() {
final manager = PermissionScope.of(context);
manager.clearServerPermissions();
final allActions = PermissionAction.values.toSet();
final overrides = <String, Set<PermissionAction>>{};
final dashboardResource = PermissionResources.normalize(dashboardRoutePath);
if (dashboardResource.isNotEmpty) {
overrides[dashboardResource] = allActions;
}
for (final page in allAppPages) {
final resource = PermissionResources.normalize(page.path);
if (resource.isEmpty) {
continue;
}
overrides[resource] = allActions;
}
manager.updateOverrides(overrides);
setState(() {
errorMessage = null;
isLoading = false;
});
if (!mounted) {
return;
}
context.go(dashboardRoutePath);
}
Future<void> _synchronizePermissions() async {
final manager = PermissionScope.of(context);
manager.clearServerPermissions();
final groupRepository = GetIt.I<GroupRepository>();
final defaultGroups = await groupRepository.list(
page: 1,
pageSize: 1,
isDefault: true,
);
Group? targetGroup = _firstGroupWithId(defaultGroups.items);
if (targetGroup == null) {
final fallbackGroups = await groupRepository.list(page: 1, pageSize: 1);
targetGroup = _firstGroupWithId(fallbackGroups.items);
}
if (targetGroup?.id == null) {
return;
}
final permissionRepository = GetIt.I<GroupPermissionRepository>();
final synchronizer = PermissionSynchronizer(
repository: permissionRepository,
manager: manager,
);
final groupId = targetGroup!.id!;
await synchronizer.syncForGroup(groupId);
}
Group? _firstGroupWithId(List<Group> groups) {
for (final group in groups) {
if (group.id != null) {
return group;
}
}
return null;
}
}

View File

@@ -1,17 +1,26 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get_it/get_it.dart';
import 'package:intl/intl.dart' as intl;
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
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/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/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';
import 'package:superport_v2/features/reporting/domain/entities/report_export_format.dart';
import 'package:superport_v2/features/reporting/domain/entities/report_export_request.dart';
import 'package:superport_v2/features/reporting/domain/repositories/reporting_repository.dart';
import 'package:superport_v2/widgets/app_layout.dart';
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 {
@@ -24,7 +33,37 @@ class ReportingPage extends StatefulWidget {
/// 보고서 페이지 UI 상태와 필터 조건을 관리하는 상태 클래스.
class _ReportingPageState extends State<ReportingPage> {
late final WarehouseRepository _warehouseRepository;
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;
String? _lookupError;
bool _isExporting = false;
String? _exportError;
ReportDownloadResult? _lastResult;
ReportExportFormat? _lastFormat;
static const Map<ReportTypeFilter, List<String>> _transactionTypeKeywords = {
ReportTypeFilter.inbound: ['입고', 'inbound'],
ReportTypeFilter.outbound: ['출고', 'outbound'],
ReportTypeFilter.rental: ['대여', 'rent', 'rental'],
};
static const Map<ReportStatusFilter, List<String>>
_transactionStatusKeywords = {
ReportStatusFilter.inProgress: ['작성', '대기', '진행', 'pending'],
ReportStatusFilter.completed: ['완료', '승인', 'complete', 'approved'],
ReportStatusFilter.cancelled: ['취소', '반려', 'cancel', 'rejected'],
};
static const Map<ReportStatusFilter, List<String>> _approvalStatusKeywords = {
ReportStatusFilter.inProgress: ['진행', '대기', 'processing'],
ReportStatusFilter.completed: ['완료', '승인', 'approved'],
ReportStatusFilter.cancelled: ['취소', '반려', 'cancel'],
};
DateTimeRange? _appliedDateRange;
DateTimeRange? _pendingDateRange;
@@ -41,10 +80,28 @@ class _ReportingPageState extends State<ReportingPage> {
bool _isLoadingWarehouses = false;
String? _warehouseError;
/// 실패 객체에서 사용자 메시지를 추출한다.
String _failureMessage(Object error, String fallback) {
final failure = Failure.from(error);
final description = failure.describe();
if (description.isEmpty) {
return fallback;
}
return description;
}
@override
void initState() {
super.initState();
_warehouseRepository = GetIt.I<WarehouseRepository>();
final getIt = GetIt.I;
_warehouseRepository = getIt<WarehouseRepository>();
if (getIt.isRegistered<ReportingRepository>()) {
_reportingRepository = getIt<ReportingRepository>();
}
if (getIt.isRegistered<InventoryLookupRepository>()) {
_lookupRepository = getIt<InventoryLookupRepository>();
_loadLookups();
}
_loadWarehouses();
}
@@ -85,12 +142,17 @@ class _ReportingPageState extends State<ReportingPage> {
}
} catch (error) {
if (mounted) {
final message = _failureMessage(
error,
'창고 목록을 불러오지 못했습니다. 잠시 후 다시 시도하세요.',
);
setState(() {
_warehouseError = '창고 목록을 불러오지 못했습니다. 잠시 후 다시 시도하세요.';
_warehouseError = message;
_warehouseOptions = const [WarehouseFilterOption.all];
_appliedWarehouse = WarehouseFilterOption.all;
_pendingWarehouse = WarehouseFilterOption.all;
});
SuperportToast.error(context, message);
}
} finally {
if (mounted) {
@@ -101,6 +163,60 @@ class _ReportingPageState extends State<ReportingPage> {
}
}
Future<void> _loadLookups() async {
final repository = _lookupRepository;
if (repository == null) {
return;
}
setState(() {
_isLoadingLookups = true;
_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(
_mapStatusByKeyword(
transactionStatuses,
_transactionStatusKeywords,
),
);
_approvalStatusLookup
..clear()
..addAll(
_mapStatusByKeyword(approvalStatuses, _approvalStatusKeywords),
);
});
} catch (error) {
if (mounted) {
final message = _failureMessage(
error,
'유형/상태 정보를 불러오지 못했습니다. 잠시 후 다시 시도하세요.',
);
setState(() {
_lookupError = message;
});
SuperportToast.error(context, message);
}
} finally {
if (mounted) {
setState(() {
_isLoadingLookups = false;
});
}
}
}
/// 적용된 필터를 초기 상태로 되돌린다.
void _resetFilters() {
setState(() {
@@ -166,6 +282,69 @@ 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,
) {
final result = <ReportStatusFilter, LookupItem>{};
for (final entry in keywords.entries) {
final matched = _matchLookup(items, entry.value);
if (matched != null) {
result[entry.key] = matched;
}
}
return result;
}
LookupItem? _matchLookup(List<LookupItem> items, List<String> keywords) {
final normalized = keywords
.map((keyword) => keyword.trim().toLowerCase())
.where((keyword) => keyword.isNotEmpty)
.toList(growable: false);
if (normalized.isEmpty) {
return null;
}
for (final item in items) {
final name = item.name.trim().toLowerCase();
if (normalized.any((keyword) => name.contains(keyword))) {
return item;
}
}
return null;
}
int? _resolveTransactionTypeId() {
if (_appliedType == ReportTypeFilter.all ||
_appliedType == ReportTypeFilter.approval) {
return null;
}
final lookup = _transactionTypeLookup[_appliedType];
return lookup?.id;
}
int? _resolveStatusId() {
if (_appliedStatus == ReportStatusFilter.all) {
return null;
}
if (_appliedType == ReportTypeFilter.approval) {
return _approvalStatusLookup[_appliedStatus]?.id;
}
return _transactionStatusLookup[_appliedStatus]?.id;
}
String _dateRangeLabel(DateTimeRange? range) {
if (range == null) {
return '기간 선택';
@@ -175,8 +354,159 @@ class _ReportingPageState extends State<ReportingPage> {
String _formatDate(DateTime value) => _dateFormat.format(value);
void _handleExport(ReportExportFormat format) {
SuperportToast.info(context, '${format.label} 다운로드 연동은 준비 중입니다.');
Future<void> _handleExport(ReportExportFormat format) async {
if (_isExporting) {
return;
}
final repository = _reportingRepository;
final range = _appliedDateRange;
if (repository == null) {
SuperportToast.error(context, '보고서 다운로드 리포지토리가 초기화되지 않았습니다.');
return;
}
if (range == null) {
SuperportToast.error(context, '기간을 먼저 선택하세요.');
return;
}
setState(() {
_isExporting = true;
_exportError = null;
});
final request = ReportExportRequest(
from: range.start,
to: range.end,
format: format,
transactionTypeId: _resolveTransactionTypeId(),
statusId: _resolveStatusId(),
warehouseId: _appliedWarehouse.id,
);
try {
final result = _appliedType == ReportTypeFilter.approval
? await repository.exportApprovals(request)
: await repository.exportTransactions(request);
if (!mounted) {
return;
}
setState(() {
_lastResult = result;
_lastFormat = format;
});
if (result.hasDownloadUrl) {
SuperportToast.success(context, '다운로드 링크가 준비되었습니다.');
} else if (result.hasBytes) {
SuperportToast.success(context, '보고서 파일이 준비되었습니다. 저장 기능은 추후 제공 예정입니다.');
} else {
SuperportToast.info(context, '다운로드 결과를 확인했지만 추가 처리 항목이 없습니다.');
}
} catch (error) {
if (!mounted) {
return;
}
final message = _failureMessage(
error,
'보고서 다운로드에 실패했습니다. 잠시 후 다시 시도하세요.',
);
setState(() {
_exportError = message;
});
SuperportToast.error(context, message);
} finally {
if (mounted) {
setState(() {
_isExporting = false;
});
}
}
}
Future<void> _launchDownloadUrl(Uri url) async {
try {
final opened = await launchUrl(url, mode: LaunchMode.externalApplication);
if (!opened && mounted) {
SuperportToast.error(context, '다운로드 링크를 열 수 없습니다.');
}
} catch (_) {
if (mounted) {
SuperportToast.error(context, '다운로드 링크를 열 수 없습니다.');
}
}
}
Future<void> _copyDownloadUrl(Uri url) async {
await Clipboard.setData(ClipboardData(text: url.toString()));
if (mounted) {
SuperportToast.success(context, '다운로드 URL을 복사했습니다.');
}
}
Widget _buildPreview(ShadThemeData theme) {
if (_isExporting) {
return const Center(child: CircularProgressIndicator());
}
if (_exportError != null) {
return Center(
child: Text(
_exportError!,
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.destructive,
),
),
);
}
final result = _lastResult;
if (result == null) {
return SuperportEmptyState(
icon: lucide.LucideIcons.chartBar,
title: '미리보기 데이터가 없습니다.',
description: '필터를 적용하거나 보고서를 다운로드하면 이 영역에 요약이 표시됩니다.',
);
}
final rows = <Widget>[
if (_lastFormat != null)
_SummaryRow(label: '형식', value: _lastFormat!.label),
if (result.filename != null)
_SummaryRow(label: '파일명', value: result.filename!),
if (result.mimeType != null)
_SummaryRow(label: 'MIME', value: result.mimeType!),
if (result.expiresAt != null)
_SummaryRow(
label: '만료 시각',
value: intl.DateFormat('yyyy-MM-dd HH:mm').format(result.expiresAt!),
),
if (result.downloadUrl != null)
_SummaryRow(label: '다운로드 URL', value: result.downloadUrl!.toString()),
if (result.hasBytes && (result.downloadUrl == null))
const _SummaryRow(
label: '상태',
value: '바이너리 응답이 준비되었습니다. 저장 기능은 추후 제공 예정입니다.',
),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...rows,
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
if (result.downloadUrl != null)
ShadButton(
onPressed: () => _launchDownloadUrl(result.downloadUrl!),
leading: const Icon(lucide.LucideIcons.externalLink, size: 16),
child: const Text('다운로드 열기'),
),
if (result.downloadUrl != null)
ShadButton.outline(
onPressed: () => _copyDownloadUrl(result.downloadUrl!),
leading: const Icon(lucide.LucideIcons.copy, size: 16),
child: const Text('URL 복사'),
),
],
),
],
);
}
@override
@@ -241,13 +571,26 @@ class _ReportingPageState extends State<ReportingPage> {
child: ShadSelect<ReportTypeFilter>(
key: ValueKey(_pendingType),
initialValue: _pendingType,
selectedOptionBuilder: (_, value) => Text(value.label),
onChanged: (value) {
if (value == null) return;
setState(() {
_pendingType = value;
});
selectedOptionBuilder: (context, value) {
final theme = ShadTheme.of(context);
if (_lookupError != null) {
return Text(
_lookupError!,
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.destructive,
),
);
}
return Text(value.label);
},
onChanged: _isLoadingLookups
? null
: (value) {
if (value == null) return;
setState(() {
_pendingType = value;
});
},
options: [
for (final type in ReportTypeFilter.values)
ShadOption(value: type, child: Text(type.label)),
@@ -279,13 +622,26 @@ class _ReportingPageState extends State<ReportingPage> {
child: ShadSelect<ReportStatusFilter>(
key: ValueKey(_pendingStatus),
initialValue: _pendingStatus,
selectedOptionBuilder: (_, value) => Text(value.label),
onChanged: (value) {
if (value == null) return;
setState(() {
_pendingStatus = value;
});
selectedOptionBuilder: (context, value) {
final theme = ShadTheme.of(context);
if (_lookupError != null) {
return Text(
_lookupError!,
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.destructive,
),
);
}
return Text(value.label);
},
onChanged: _isLoadingLookups
? null
: (value) {
if (value == null) return;
setState(() {
_pendingStatus = value;
});
},
options: [
for (final status in ReportStatusFilter.values)
ShadOption(value: status, child: Text(status.label)),
@@ -373,15 +729,18 @@ class _ReportingPageState extends State<ReportingPage> {
ShadCard(
title: Text('보고서 미리보기', style: theme.textTheme.h3),
description: Text(
'조건을 적용하면 다운로드 진행 상태와 결과 테이블이 이 영역에 표시됩니다.',
'다운로드 상태와 결과 요약을 확인하세요.',
style: theme.textTheme.muted,
),
child: SizedBox(
height: 240,
child: SuperportEmptyState(
icon: lucide.LucideIcons.chartBar,
title: '미리보기 데이터가 없습니다.',
description: '필터를 적용하거나 보고서를 다운로드하면 이 영역에 요약이 표시됩니다.',
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 200),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: _buildPreview(theme),
),
),
),
),
@@ -458,15 +817,6 @@ extension ReportStatusFilterX on ReportStatusFilter {
}
}
enum ReportExportFormat { xlsx, pdf }
extension ReportExportFormatX on ReportExportFormat {
String get label => switch (this) {
ReportExportFormat.xlsx => 'XLSX',
ReportExportFormat.pdf => 'PDF',
};
}
class WarehouseFilterOption {
const WarehouseFilterOption({this.id, required this.label});