feat: 장비 이력 화면을 팝업 다이얼로그로 개선
- 기존 전체 화면 방식에서 팝업 다이얼로그 방식으로 변경 - 실시간 검색 필터링 기능 추가 - 모던한 UI 디자인 적용 (카드 스타일, 색상 코딩) - 반응형 크기 조정 (데스크톱/모바일 대응) - ESC 키로 닫기 지원 - 불필요한 라우팅 코드 및 파일 정리 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,351 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:superport/data/models/equipment/equipment_history_dto.dart';
|
||||
import 'package:superport/services/equipment_service.dart';
|
||||
import 'package:superport/screens/common/custom_widgets.dart';
|
||||
import 'package:superport/core/errors/failures.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class EquipmentHistoryScreen extends StatefulWidget {
|
||||
final int equipmentId;
|
||||
final String equipmentName;
|
||||
|
||||
const EquipmentHistoryScreen({
|
||||
Key? key,
|
||||
required this.equipmentId,
|
||||
required this.equipmentName,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<EquipmentHistoryScreen> createState() => _EquipmentHistoryScreenState();
|
||||
}
|
||||
|
||||
class _EquipmentHistoryScreenState extends State<EquipmentHistoryScreen> {
|
||||
final EquipmentService _equipmentService = GetIt.instance<EquipmentService>();
|
||||
List<EquipmentHistoryDto> _histories = [];
|
||||
bool _isLoading = false; // 초기값을 false로 변경
|
||||
bool _isInitialLoad = true; // 초기 로딩 상태 추가
|
||||
String? _error;
|
||||
int _currentPage = 1;
|
||||
final int _perPage = 20;
|
||||
bool _hasMore = true;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
print('[INIT] EquipmentHistoryScreen initialized for equipment ${widget.equipmentId}');
|
||||
_loadHistory(isInitialLoad: true);
|
||||
_scrollController.addListener(_onScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
|
||||
_loadMoreHistory();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadHistory({bool isRefresh = false, bool isInitialLoad = false}) async {
|
||||
print('[_loadHistory] Called - isRefresh: $isRefresh, isInitialLoad: $isInitialLoad, _isLoading: $_isLoading');
|
||||
|
||||
if (isRefresh) {
|
||||
_currentPage = 1;
|
||||
_hasMore = true;
|
||||
_histories.clear();
|
||||
}
|
||||
|
||||
// 초기 로딩이 아닌 경우에만 중복 호출 방지
|
||||
if (!isInitialLoad && (!_hasMore || (!isRefresh && _isLoading))) {
|
||||
print('[_loadHistory] Skipping - hasMore: $_hasMore, isLoading: $_isLoading');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
if (isInitialLoad) _isInitialLoad = false;
|
||||
});
|
||||
|
||||
try {
|
||||
print('[DEBUG] ==== STARTING HISTORY LOAD ====');
|
||||
print('[DEBUG] Equipment ID: ${widget.equipmentId}');
|
||||
print('[DEBUG] Equipment Name: ${widget.equipmentName}');
|
||||
print('[DEBUG] Page: $_currentPage, PerPage: $_perPage');
|
||||
print('[DEBUG] Current time: ${DateTime.now()}');
|
||||
|
||||
// 타임아웃 설정
|
||||
final histories = await _equipmentService.getEquipmentHistory(
|
||||
widget.equipmentId,
|
||||
page: _currentPage,
|
||||
perPage: _perPage,
|
||||
).timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
print('[ERROR] API call timeout after 10 seconds');
|
||||
throw Exception('API 호출 시간 초과 (10초)');
|
||||
},
|
||||
);
|
||||
|
||||
print('[DEBUG] API call completed successfully');
|
||||
print('[DEBUG] Received ${histories.length} history records');
|
||||
if (histories.isNotEmpty) {
|
||||
print('[DEBUG] First history record: ${histories.first.toJson()}');
|
||||
} else {
|
||||
print('[DEBUG] No history records found');
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
print('[WARNING] Widget not mounted, skipping setState');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
if (isRefresh) {
|
||||
_histories = histories;
|
||||
} else {
|
||||
_histories.addAll(histories);
|
||||
}
|
||||
_hasMore = histories.length == _perPage;
|
||||
if (_hasMore) _currentPage++;
|
||||
_isLoading = false;
|
||||
print('[DEBUG] State updated - Loading: false, Histories count: ${_histories.length}');
|
||||
});
|
||||
} on Failure catch (e) {
|
||||
print('[ERROR] Failure loading history: ${e.message}');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e.message;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e, stackTrace) {
|
||||
print('[ERROR] Unexpected error loading history: $e');
|
||||
print('[ERROR] Stack trace: $stackTrace');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = '이력을 불러오는 중 오류가 발생했습니다: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
} finally {
|
||||
print('[DEBUG] ==== HISTORY LOAD COMPLETED ====');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMoreHistory() async {
|
||||
await _loadHistory();
|
||||
}
|
||||
|
||||
String _formatDate(DateTime? date) {
|
||||
if (date == null) return '-';
|
||||
return DateFormat('yyyy-MM-dd HH:mm').format(date);
|
||||
}
|
||||
|
||||
String _getTransactionTypeText(String? type) {
|
||||
switch (type) {
|
||||
case 'I':
|
||||
return '입고';
|
||||
case 'O':
|
||||
return '출고';
|
||||
case 'R':
|
||||
return '대여';
|
||||
case 'T':
|
||||
return '반납';
|
||||
case 'D':
|
||||
return '폐기';
|
||||
default:
|
||||
return type ?? '-';
|
||||
}
|
||||
}
|
||||
|
||||
Color _getTransactionTypeColor(String? type) {
|
||||
switch (type) {
|
||||
case 'I':
|
||||
return Colors.green;
|
||||
case 'O':
|
||||
return Colors.blue;
|
||||
case 'R':
|
||||
return Colors.orange;
|
||||
case 'T':
|
||||
return Colors.teal;
|
||||
case 'D':
|
||||
return Colors.red;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildHistoryItem(EquipmentHistoryDto history) {
|
||||
final typeColor = _getTransactionTypeColor(history.transactionType);
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: typeColor.withOpacity(0.2),
|
||||
child: Text(
|
||||
_getTransactionTypeText(history.transactionType),
|
||||
style: TextStyle(
|
||||
color: typeColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
history.remarks ?? '비고 없음',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'수량: ${history.quantity}',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Text(_formatDate(history.transactionDate)),
|
||||
if (history.userName != null)
|
||||
Text('담당자: ${history.userName}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
print('[BUILD] EquipmentHistoryScreen - Loading: $_isLoading, InitialLoad: $_isInitialLoad, Error: $_error, Histories: ${_histories.length}');
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('장비 이력'),
|
||||
Text(
|
||||
widget.equipmentName,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () => _loadHistory(isRefresh: true),
|
||||
tooltip: '새로고침',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
print('[UI] Building body - Loading: $_isLoading, InitialLoad: $_isInitialLoad, Error: $_error, Histories: ${_histories.length}');
|
||||
|
||||
// 초기 로딩 또는 일반 로딩 중
|
||||
if ((_isInitialLoad || _isLoading) && _histories.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text('장비 ID ${widget.equipmentId}의 이력을 불러오는 중...'),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
print('[USER] Cancel loading clicked');
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_error = '사용자가 로딩을 취소했습니다';
|
||||
});
|
||||
},
|
||||
child: const Text('로딩 취소'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_error != null && _histories.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
||||
const SizedBox(height: 16),
|
||||
Text('오류 발생', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('다시 시도'),
|
||||
onPressed: () => _loadHistory(isRefresh: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => _loadHistory(isRefresh: true),
|
||||
child: _histories.isEmpty
|
||||
? ListView(
|
||||
children: [
|
||||
const SizedBox(height: 200),
|
||||
const Icon(Icons.history, size: 48, color: Colors.grey),
|
||||
const SizedBox(height: 16),
|
||||
const Center(
|
||||
child: Text(
|
||||
'이력이 없습니다.',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: () => _loadHistory(isRefresh: true),
|
||||
child: const Text('새로고침'),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: ListView.builder(
|
||||
controller: _scrollController,
|
||||
itemCount: _histories.length + (_hasMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == _histories.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
return _buildHistoryItem(_histories[index]);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import 'package:superport/services/mock_data_service.dart';
|
||||
import 'package:superport/models/equipment_unified_model.dart';
|
||||
import 'package:superport/utils/constants.dart';
|
||||
import 'package:superport/utils/equipment_display_helper.dart';
|
||||
import 'package:superport/screens/equipment/widgets/equipment_history_dialog.dart';
|
||||
|
||||
/// shadcn/ui 스타일로 재설계된 장비 관리 화면
|
||||
class EquipmentListRedesign extends StatefulWidget {
|
||||
@@ -362,13 +363,11 @@ class _EquipmentListRedesignState extends State<EquipmentListRedesign> {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await Navigator.pushNamed(
|
||||
context,
|
||||
Routes.equipmentHistory,
|
||||
arguments: {
|
||||
'equipmentId': equipment.equipment.id,
|
||||
'equipmentName': '${equipment.equipment.manufacturer} ${equipment.equipment.name}',
|
||||
},
|
||||
// 팝업 다이얼로그로 이력 표시
|
||||
final result = await EquipmentHistoryDialog.show(
|
||||
context: context,
|
||||
equipmentId: equipment.equipment.id!,
|
||||
equipmentName: '${equipment.equipment.manufacturer} ${equipment.equipment.name}',
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:superport/services/equipment_service.dart';
|
||||
import 'package:superport/data/models/equipment/equipment_history_dto.dart';
|
||||
|
||||
class TestHistoryScreen extends StatefulWidget {
|
||||
const TestHistoryScreen({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<TestHistoryScreen> createState() => _TestHistoryScreenState();
|
||||
}
|
||||
|
||||
class _TestHistoryScreenState extends State<TestHistoryScreen> {
|
||||
final EquipmentService _equipmentService = GetIt.instance<EquipmentService>();
|
||||
final TextEditingController _idController = TextEditingController(text: '1');
|
||||
List<EquipmentHistoryDto>? _histories;
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
|
||||
Future<void> _testAddHistory() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final equipmentId = int.tryParse(_idController.text) ?? 1;
|
||||
|
||||
// 테스트 이력 추가
|
||||
await _equipmentService.addEquipmentHistory(
|
||||
equipmentId,
|
||||
'I', // transaction type
|
||||
10, // quantity
|
||||
'Test history added at ${DateTime.now()}', // remarks
|
||||
);
|
||||
|
||||
// 이력 다시 조회
|
||||
await _testGetHistory();
|
||||
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'Error adding history: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _testGetHistory() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
_histories = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final equipmentId = int.tryParse(_idController.text) ?? 1;
|
||||
|
||||
print('[TEST] Fetching history for equipment ID: $equipmentId');
|
||||
|
||||
final histories = await _equipmentService.getEquipmentHistory(
|
||||
equipmentId,
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
);
|
||||
|
||||
print('[TEST] Received ${histories.length} history records');
|
||||
|
||||
setState(() {
|
||||
_histories = histories;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
print('[TEST ERROR] $e');
|
||||
setState(() {
|
||||
_error = 'Error: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Equipment History Test'),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _idController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Equipment ID',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _testGetHistory,
|
||||
child: const Text('Get History'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _testAddHistory,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
child: const Text('Add Test History'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_isLoading)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (_error != null)
|
||||
Card(
|
||||
color: Colors.red[50],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
_error!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (_histories != null)
|
||||
Expanded(
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Found ${_histories!.length} history records',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Divider(),
|
||||
Expanded(
|
||||
child: _histories!.isEmpty
|
||||
? const Center(child: Text('No history found'))
|
||||
: ListView.builder(
|
||||
itemCount: _histories!.length,
|
||||
itemBuilder: (context, index) {
|
||||
final history = _histories![index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
child: Text(history.transactionType),
|
||||
),
|
||||
title: Text('Quantity: ${history.quantity}'),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Remarks: ${history.remarks ?? "N/A"}'),
|
||||
Text('Date: ${history.transactionDate}'),
|
||||
Text('User: ${history.userName ?? "Unknown"}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
647
lib/screens/equipment/widgets/equipment_history_dialog.dart
Normal file
647
lib/screens/equipment/widgets/equipment_history_dialog.dart
Normal file
@@ -0,0 +1,647 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:superport/data/models/equipment/equipment_history_dto.dart';
|
||||
import 'package:superport/services/equipment_service.dart';
|
||||
import 'package:superport/core/errors/failures.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// 장비 이력을 표시하는 팝업 다이얼로그
|
||||
class EquipmentHistoryDialog extends StatefulWidget {
|
||||
final int equipmentId;
|
||||
final String equipmentName;
|
||||
|
||||
const EquipmentHistoryDialog({
|
||||
Key? key,
|
||||
required this.equipmentId,
|
||||
required this.equipmentName,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<EquipmentHistoryDialog> createState() => _EquipmentHistoryDialogState();
|
||||
|
||||
/// 다이얼로그를 표시하는 정적 메서드
|
||||
static Future<bool?> show({
|
||||
required BuildContext context,
|
||||
required int equipmentId,
|
||||
required String equipmentName,
|
||||
}) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (BuildContext context) {
|
||||
return EquipmentHistoryDialog(
|
||||
equipmentId: equipmentId,
|
||||
equipmentName: equipmentName,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EquipmentHistoryDialogState extends State<EquipmentHistoryDialog> {
|
||||
final EquipmentService _equipmentService = GetIt.instance<EquipmentService>();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
List<EquipmentHistoryDto> _histories = [];
|
||||
List<EquipmentHistoryDto> _filteredHistories = [];
|
||||
bool _isLoading = false;
|
||||
bool _isInitialLoad = true;
|
||||
String? _error;
|
||||
int _currentPage = 1;
|
||||
final int _perPage = 20;
|
||||
bool _hasMore = true;
|
||||
String _searchQuery = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadHistory(isInitialLoad: true);
|
||||
_scrollController.addListener(_onScroll);
|
||||
_searchController.addListener(_onSearchChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 200) {
|
||||
_loadMoreHistory();
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchChanged() {
|
||||
setState(() {
|
||||
_searchQuery = _searchController.text.toLowerCase();
|
||||
_filterHistories();
|
||||
});
|
||||
}
|
||||
|
||||
void _filterHistories() {
|
||||
if (_searchQuery.isEmpty) {
|
||||
_filteredHistories = List.from(_histories);
|
||||
} else {
|
||||
_filteredHistories = _histories.where((history) {
|
||||
final remarks = (history.remarks ?? '').toLowerCase();
|
||||
final userName = (history.userName ?? '').toLowerCase();
|
||||
final type = _getTransactionTypeText(history.transactionType).toLowerCase();
|
||||
|
||||
return remarks.contains(_searchQuery) ||
|
||||
userName.contains(_searchQuery) ||
|
||||
type.contains(_searchQuery);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadHistory({
|
||||
bool isRefresh = false,
|
||||
bool isInitialLoad = false
|
||||
}) async {
|
||||
if (isRefresh) {
|
||||
_currentPage = 1;
|
||||
_hasMore = true;
|
||||
_histories.clear();
|
||||
_filteredHistories.clear();
|
||||
}
|
||||
|
||||
if (!isInitialLoad && (!_hasMore || (!isRefresh && _isLoading))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
if (isInitialLoad) _isInitialLoad = false;
|
||||
});
|
||||
|
||||
try {
|
||||
final histories = await _equipmentService.getEquipmentHistory(
|
||||
widget.equipmentId,
|
||||
page: _currentPage,
|
||||
perPage: _perPage,
|
||||
).timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
throw Exception('API 호출 시간 초과 (10초)');
|
||||
},
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
if (isRefresh) {
|
||||
_histories = histories;
|
||||
} else {
|
||||
_histories.addAll(histories);
|
||||
}
|
||||
_filterHistories();
|
||||
_hasMore = histories.length == _perPage;
|
||||
if (_hasMore) _currentPage++;
|
||||
_isLoading = false;
|
||||
});
|
||||
} on Failure catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e.message;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = '이력을 불러오는 중 오류가 발생했습니다: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMoreHistory() async {
|
||||
await _loadHistory();
|
||||
}
|
||||
|
||||
String _formatDate(DateTime? date) {
|
||||
if (date == null) return '-';
|
||||
return DateFormat('yyyy-MM-dd HH:mm').format(date);
|
||||
}
|
||||
|
||||
String _getTransactionTypeText(String? type) {
|
||||
switch (type) {
|
||||
case 'I':
|
||||
return '입고';
|
||||
case 'O':
|
||||
return '출고';
|
||||
case 'R':
|
||||
return '대여';
|
||||
case 'T':
|
||||
return '반납';
|
||||
case 'D':
|
||||
return '폐기';
|
||||
default:
|
||||
return type ?? '-';
|
||||
}
|
||||
}
|
||||
|
||||
Color _getTransactionTypeColor(String? type) {
|
||||
switch (type) {
|
||||
case 'I':
|
||||
return Colors.green;
|
||||
case 'O':
|
||||
return Colors.blue;
|
||||
case 'R':
|
||||
return Colors.orange;
|
||||
case 'T':
|
||||
return Colors.teal;
|
||||
case 'D':
|
||||
return Colors.red;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildHistoryItem(EquipmentHistoryDto history) {
|
||||
final typeColor = _getTransactionTypeColor(history.transactionType);
|
||||
final typeText = _getTransactionTypeText(history.transactionType);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.02),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () {}, // 클릭 효과를 위한 빈 핸들러
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 타입 아이콘
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: typeColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
typeText,
|
||||
style: TextStyle(
|
||||
color: typeColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 내용
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
history.remarks ?? '비고 없음',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 2,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'수량: ${history.quantity}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 14,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatDate(history.transactionDate),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
if (history.userName != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
Icon(
|
||||
Icons.person_outline,
|
||||
size: 14,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
history.userName!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final bool isDesktop = screenSize.width > 768;
|
||||
|
||||
// 반응형 크기 설정
|
||||
final dialogWidth = isDesktop
|
||||
? screenSize.width * 0.7
|
||||
: screenSize.width * 0.95;
|
||||
final dialogHeight = isDesktop
|
||||
? screenSize.height * 0.75
|
||||
: screenSize.height * 0.85;
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
insetPadding: EdgeInsets.symmetric(
|
||||
horizontal: isDesktop ? 40 : 10,
|
||||
vertical: isDesktop ? 40 : 20,
|
||||
),
|
||||
child: RawKeyboardListener(
|
||||
focusNode: FocusNode(),
|
||||
autofocus: true,
|
||||
onKey: (RawKeyEvent event) {
|
||||
if (event is RawKeyDownEvent &&
|
||||
event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
width: dialogWidth,
|
||||
height: dialogHeight,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 900,
|
||||
maxHeight: 700,
|
||||
minWidth: 320,
|
||||
minHeight: 400,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.15),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 헤더
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(12),
|
||||
topRight: Radius.circular(12),
|
||||
),
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.history,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'장비 이력',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
widget.equipmentName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 검색 필드
|
||||
SizedBox(
|
||||
width: 200,
|
||||
height: 36,
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: '검색...',
|
||||
hintStyle: TextStyle(fontSize: 13),
|
||||
prefixIcon: const Icon(Icons.search, size: 18),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade100,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 새로고침 버튼
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _isLoading ? null : () => _loadHistory(isRefresh: true),
|
||||
tooltip: '새로고침',
|
||||
),
|
||||
// 닫기 버튼
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
tooltip: '닫기',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 본문
|
||||
Expanded(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
// 초기 로딩
|
||||
if ((_isInitialLoad || _isLoading) && _histories.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text('이력을 불러오는 중...'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (_error != null && _histories.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: Colors.red.shade400,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'오류 발생',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.red.shade600,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
label: const Text('다시 시도'),
|
||||
onPressed: () => _loadHistory(isRefresh: true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터 없음
|
||||
if (_filteredHistories.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.inbox_outlined,
|
||||
size: 48,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_searchQuery.isNotEmpty
|
||||
? '검색 결과가 없습니다.'
|
||||
: '이력이 없습니다.',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
if (_searchQuery.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
},
|
||||
child: const Text('검색 초기화'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 이력 목록
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
itemCount: _filteredHistories.length + (_hasMore && _searchQuery.isEmpty ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == _filteredHistories.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _buildHistoryItem(_filteredHistories[index]);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// 푸터
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(12),
|
||||
bottomRight: Radius.circular(12),
|
||||
),
|
||||
border: Border(
|
||||
top: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'총 ${_histories.length}개 이력',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
if (_isLoading && _histories.isNotEmpty)
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'더 불러오는 중...',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user