feat: 장비 이력 화면을 팝업 다이얼로그로 개선
- 기존 전체 화면 방식에서 팝업 다이얼로그 방식으로 변경 - 실시간 검색 필터링 기능 추가 - 모던한 UI 디자인 적용 (카드 스타일, 색상 코딩) - 반응형 크기 조정 (데스크톱/모바일 대응) - ESC 키로 닫기 지원 - 불필요한 라우팅 코드 및 파일 정리 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
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