고객사 목록 쿼리스트링 연동 및 공통 JSON 파서 도입

This commit is contained in:
JiWoong Sul
2025-09-25 20:13:46 +09:00
parent 8a6ad1e81b
commit 900990c46b
27 changed files with 1458 additions and 176 deletions

View File

@@ -1,19 +1,169 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../core/constants/app_sections.dart';
import '../../../../widgets/app_layout.dart';
import '../../../../widgets/components/coming_soon_card.dart';
import '../../../../widgets/components/filter_bar.dart';
import 'package:superport_v2/core/constants/app_sections.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/widgets/app_layout.dart';
import 'package:superport_v2/widgets/components/empty_state.dart';
import 'package:superport_v2/widgets/components/filter_bar.dart';
class ReportingPage extends StatelessWidget {
class ReportingPage extends StatefulWidget {
const ReportingPage({super.key});
@override
State<ReportingPage> createState() => _ReportingPageState();
}
class _ReportingPageState extends State<ReportingPage> {
late final WarehouseRepository _warehouseRepository;
final DateFormat _dateFormat = DateFormat('yyyy.MM.dd');
DateTimeRange? _dateRange;
ReportTypeFilter _selectedType = ReportTypeFilter.all;
ReportStatusFilter _selectedStatus = ReportStatusFilter.all;
WarehouseFilterOption _selectedWarehouse = WarehouseFilterOption.all;
List<WarehouseFilterOption> _warehouseOptions = const [
WarehouseFilterOption.all,
];
bool _isLoadingWarehouses = false;
String? _warehouseError;
@override
void initState() {
super.initState();
_warehouseRepository = GetIt.I<WarehouseRepository>();
_loadWarehouses();
}
/// 활성 창고 목록을 불러와 드롭다운 옵션을 준비한다.
Future<void> _loadWarehouses() async {
setState(() {
_isLoadingWarehouses = true;
_warehouseError = null;
});
try {
final result = await _warehouseRepository.list(
pageSize: 100,
isActive: true,
);
if (!mounted) {
return;
}
final seen = <String>{WarehouseFilterOption.all.cacheKey};
final options = <WarehouseFilterOption>[WarehouseFilterOption.all];
for (final warehouse in result.items) {
final option = WarehouseFilterOption.fromWarehouse(warehouse);
if (seen.add(option.cacheKey)) {
options.add(option);
}
}
if (mounted) {
setState(() {
_warehouseOptions = options;
WarehouseFilterOption nextSelected = WarehouseFilterOption.all;
for (final option in options) {
if (option == _selectedWarehouse) {
nextSelected = option;
break;
}
}
_selectedWarehouse = nextSelected;
});
}
} catch (error) {
if (mounted) {
setState(() {
_warehouseError = '창고 목록을 불러오지 못했습니다. 잠시 후 다시 시도하세요.';
_warehouseOptions = const [WarehouseFilterOption.all];
_selectedWarehouse = WarehouseFilterOption.all;
});
}
} finally {
if (mounted) {
setState(() {
_isLoadingWarehouses = false;
});
}
}
}
Future<void> _pickDateRange() async {
final now = DateTime.now();
final initialRange =
_dateRange ??
DateTimeRange(
start: DateTime(
now.year,
now.month,
now.day,
).subtract(const Duration(days: 6)),
end: DateTime(now.year, now.month, now.day),
);
final picked = await showDateRangePicker(
context: context,
initialDateRange: initialRange,
firstDate: DateTime(now.year - 5),
lastDate: DateTime(now.year + 2),
helpText: '기간 선택',
saveText: '적용',
currentDate: now,
locale: Localizations.localeOf(context),
);
if (picked != null && mounted) {
setState(() {
_dateRange = picked;
});
}
}
void _resetFilters() {
setState(() {
_dateRange = null;
_selectedType = ReportTypeFilter.all;
_selectedStatus = ReportStatusFilter.all;
_selectedWarehouse = WarehouseFilterOption.all;
});
}
bool get _canExport {
return _dateRange != null && _selectedType != ReportTypeFilter.all;
}
bool get _hasCustomFilters {
return _dateRange != null ||
_selectedType != ReportTypeFilter.all ||
_selectedStatus != ReportStatusFilter.all ||
_selectedWarehouse != WarehouseFilterOption.all;
}
String get _dateRangeLabel {
final range = _dateRange;
if (range == null) {
return '기간 선택';
}
return '${_formatDate(range.start)} ~ ${_formatDate(range.end)}';
}
String _formatDate(DateTime value) => _dateFormat.format(value);
void _handleExport(ReportExportFormat format) {
final messenger = ScaffoldMessenger.of(context);
messenger.clearSnackBars();
messenger.showSnackBar(
SnackBar(content: Text('${format.label} 다운로드 연동은 준비 중입니다.')),
);
}
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return AppLayout(
title: '보고서',
subtitle: '기간, 유형, 창고 조건을 선택해 통합 보고서를 내려받을 수 있도록 준비 중입니다.',
subtitle: '조건을 선택해 입·출고와 결재 데이터를 내려받을 수 있도록 준비 중입니다.',
breadcrumbs: const [
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
AppBreadcrumbItem(label: '보고', path: '/reports'),
@@ -21,12 +171,16 @@ class ReportingPage extends StatelessWidget {
],
actions: [
ShadButton(
onPressed: null,
onPressed: _canExport
? () => _handleExport(ReportExportFormat.xlsx)
: null,
leading: const Icon(LucideIcons.fileDown, size: 16),
child: const Text('XLSX 다운로드'),
),
ShadButton.outline(
onPressed: null,
onPressed: _canExport
? () => _handleExport(ReportExportFormat.pdf)
: null,
leading: const Icon(LucideIcons.fileText, size: 16),
child: const Text('PDF 다운로드'),
),
@@ -34,38 +188,282 @@ class ReportingPage extends StatelessWidget {
toolbar: FilterBar(
children: [
ShadButton.outline(
onPressed: null,
onPressed: _pickDateRange,
leading: const Icon(LucideIcons.calendar, size: 16),
child: const Text('기간 선택 (준비중)'),
child: Text(_dateRangeLabel),
),
ShadButton.outline(
onPressed: null,
leading: const Icon(LucideIcons.layers, size: 16),
child: const Text('유형 선택 (준비중)'),
SizedBox(
width: 200,
child: ShadSelect<ReportTypeFilter>(
key: ValueKey(_selectedType),
initialValue: _selectedType,
selectedOptionBuilder: (_, value) => Text(value.label),
onChanged: (value) {
if (value == null) return;
setState(() {
_selectedType = value;
});
},
options: [
for (final type in ReportTypeFilter.values)
ShadOption(value: type, child: Text(type.label)),
],
),
),
ShadButton.outline(
onPressed: null,
leading: const Icon(LucideIcons.warehouse, size: 16),
child: const Text('창고 선택 (준비중)'),
SizedBox(
width: 220,
child: ShadSelect<WarehouseFilterOption>(
key: ValueKey(
'${_selectedWarehouse.cacheKey}-${_warehouseOptions.length}',
),
initialValue: _selectedWarehouse,
selectedOptionBuilder: (_, value) => Text(value.label),
onChanged: (value) {
if (value == null) return;
setState(() {
_selectedWarehouse = value;
});
},
options: [
for (final option in _warehouseOptions)
ShadOption(value: option, child: Text(option.label)),
],
),
),
ShadButton.outline(
onPressed: null,
leading: const Icon(LucideIcons.badgeCheck, size: 16),
child: const Text('상태 선택 (준비중)'),
SizedBox(
width: 200,
child: ShadSelect<ReportStatusFilter>(
key: ValueKey(_selectedStatus),
initialValue: _selectedStatus,
selectedOptionBuilder: (_, value) => Text(value.label),
onChanged: (value) {
if (value == null) return;
setState(() {
_selectedStatus = value;
});
},
options: [
for (final status in ReportStatusFilter.values)
ShadOption(value: status, child: Text(status.label)),
],
),
),
const ShadBadge(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Text('API 스펙 정리 후 필터가 활성화됩니다.'),
ShadButton.ghost(
onPressed: _hasCustomFilters ? _resetFilters : null,
leading: const Icon(LucideIcons.rotateCcw, size: 16),
child: const Text('초기화'),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_warehouseError != null)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
children: [
Icon(
LucideIcons.circleAlert,
size: 16,
color: theme.colorScheme.destructive,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_warehouseError!,
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.destructive,
),
),
),
const SizedBox(width: 8),
ShadButton.ghost(
onPressed: _isLoadingWarehouses ? null : _loadWarehouses,
leading: const Icon(LucideIcons.refreshCw, size: 16),
child: const Text('재시도'),
),
],
),
)
else if (_isLoadingWarehouses)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
children: [
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 8),
Text('창고 목록을 불러오는 중입니다...', style: theme.textTheme.small),
],
),
),
ShadCard(
title: Text('선택된 조건', style: theme.textTheme.h3),
description: Text(
'다운로드 전 사용자 조건을 빠르게 검토하세요.',
style: theme.textTheme.muted,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SummaryRow(
label: '기간',
value: _dateRange == null ? '기간을 선택하세요.' : _dateRangeLabel,
),
_SummaryRow(label: '유형', value: _selectedType.label),
_SummaryRow(label: '창고', value: _selectedWarehouse.label),
_SummaryRow(label: '상태', value: _selectedStatus.label),
if (!_canExport)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
'기간과 유형을 선택하면 다운로드 버튼이 활성화됩니다.',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.mutedForeground,
),
),
),
],
),
),
const SizedBox(height: 24),
ShadCard(
title: Text('보고서 미리보기', style: theme.textTheme.h3),
description: Text(
'조건을 적용하면 다운로드 진행 상태와 결과 테이블이 이 영역에 표시됩니다.',
style: theme.textTheme.muted,
),
child: SizedBox(
height: 240,
child: EmptyState(
icon: LucideIcons.chartBar,
message: '필터를 선택하고 다운로드하면 결과 미리보기가 제공됩니다.',
),
),
),
],
),
child: const ComingSoonCard(
title: '보고서 화면 구현 준비 중',
description: '입·출고/결재 데이터를 조건별로 조회하고 다운로드할 수 있는 UI를 설계 중입니다.',
items: ['조건별 보고서 템플릿 매핑', '다운로드 진행 상태 표시 및 실패 처리', '즐겨찾는 조건 저장/불러오기'],
);
}
}
class _SummaryRow extends StatelessWidget {
const _SummaryRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 72,
child: Text(
label,
style: theme.textTheme.small.copyWith(
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 12),
Expanded(child: Text(value, style: theme.textTheme.p)),
],
),
);
}
}
enum ReportTypeFilter { all, inbound, outbound, rental, approval }
extension ReportTypeFilterX on ReportTypeFilter {
String get label {
switch (this) {
case ReportTypeFilter.all:
return '전체 유형';
case ReportTypeFilter.inbound:
return '입고';
case ReportTypeFilter.outbound:
return '출고';
case ReportTypeFilter.rental:
return '대여';
case ReportTypeFilter.approval:
return '결재';
}
}
}
enum ReportStatusFilter { all, inProgress, completed, cancelled }
extension ReportStatusFilterX on ReportStatusFilter {
String get label {
switch (this) {
case ReportStatusFilter.all:
return '전체 상태';
case ReportStatusFilter.inProgress:
return '진행중';
case ReportStatusFilter.completed:
return '완료';
case ReportStatusFilter.cancelled:
return '취소';
}
}
}
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});
final int? id;
final String label;
static const WarehouseFilterOption all = WarehouseFilterOption(
id: null,
label: '전체 창고',
);
factory WarehouseFilterOption.fromWarehouse(Warehouse warehouse) {
final id = warehouse.id;
final name = warehouse.warehouseName;
final code = warehouse.warehouseCode;
return WarehouseFilterOption(
id: id,
label: id == null ? name : '$name ($code)',
);
}
String get cacheKey => id?.toString() ?? label;
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
if (other is! WarehouseFilterOption) {
return false;
}
if (id != null && other.id != null) {
return id == other.id;
}
return label == other.label;
}
@override
int get hashCode => cacheKey.hashCode;
}