고객사 목록 쿼리스트링 연동 및 공통 JSON 파서 도입
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user