import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get_it/get_it.dart'; import 'package:superport/core/constants/app_constants.dart'; import 'package:superport/data/models/equipment_history_dto.dart'; import 'package:superport/services/equipment_service.dart'; import 'package:superport/core/errors/failures.dart'; import 'package:intl/intl.dart'; import 'package:superport/screens/common/theme_shadcn.dart'; /// 장비 이력을 표시하는 팝업 다이얼로그 class EquipmentHistoryDialog extends StatefulWidget { final int equipmentId; final String equipmentName; const EquipmentHistoryDialog({ super.key, required this.equipmentId, required this.equipmentName, }); @override State createState() => _EquipmentHistoryDialogState(); /// 다이얼로그를 표시하는 정적 메서드 static Future show({ required BuildContext context, required int equipmentId, required String equipmentName, }) { return showDialog( context: context, barrierDismissible: true, builder: (BuildContext context) { return EquipmentHistoryDialog( equipmentId: equipmentId, equipmentName: equipmentName, ); }, ); } } class _EquipmentHistoryDialogState extends State { final EquipmentService _equipmentService = GetIt.instance(); final ScrollController _scrollController = ScrollController(); final TextEditingController _searchController = TextEditingController(); List _histories = []; List _filteredHistories = []; bool _isLoading = false; bool _isInitialLoad = true; String? _error; int _currentPage = 1; final int _perPage = AppConstants.historyPageSize; bool _hasMore = true; String _searchQuery = ''; // 상세보기 상태 EquipmentHistoryDto? _selectedHistory; @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.remark ?? '').toLowerCase(); final type = _getTransactionTypeText(history.transactionType).toLowerCase(); return remarks.contains(_searchQuery) || type.contains(_searchQuery); }).toList(); } } void _selectHistory(EquipmentHistoryDto history) { setState(() { _selectedHistory = history; }); } Future _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.cast(); } else { _histories.addAll(histories.cast()); } _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 _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 ShadcnTheme.equipmentIn; case 'O': return ShadcnTheme.equipmentOut; case 'R': return ShadcnTheme.equipmentRepair; case 'T': return ShadcnTheme.equipmentRent; case 'D': return ShadcnTheme.destructive; default: return ShadcnTheme.secondary; } } Widget _buildHistoryItem(EquipmentHistoryDto history) { final typeColor = _getTransactionTypeColor(history.transactionType); final typeText = _getTransactionTypeText(history.transactionType); final isSelected = _selectedHistory?.id != null && _selectedHistory?.id == history.id; return Container( margin: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration( color: isSelected ? ShadcnTheme.cardHover : ShadcnTheme.card, borderRadius: BorderRadius.circular(8), border: Border.all(color: isSelected ? ShadcnTheme.primary : ShadcnTheme.border), boxShadow: ShadcnTheme.shadowSm, ), child: Material( color: Colors.transparent, borderRadius: BorderRadius.circular(8), child: InkWell( borderRadius: BorderRadius.circular(8), onTap: () => _selectHistory(history), child: Padding( padding: const EdgeInsets.all(12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 타입 아이콘 Container( width: 40, height: 40, decoration: BoxDecoration( color: typeColor.withValues(alpha: 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.remark ?? '비고 없음', 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.transactedAt), style: TextStyle( fontSize: 12, color: Colors.grey.shade600, ), ), // userName 필드는 백엔드에 없으므로 제거됨 ], ), ], ), ), ], ), ), ), ), ); } @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: KeyboardListener( focusNode: FocusNode(), autofocus: true, onKeyEvent: (KeyEvent event) { if (event is KeyDownEvent && 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: ShadcnTheme.background, borderRadius: BorderRadius.circular(12), boxShadow: ShadcnTheme.shadowLg, ), child: Column( children: [ // 헤더 Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: ShadcnTheme.card, borderRadius: const BorderRadius.only( topLeft: Radius.circular(12), topRight: Radius.circular(12), ), border: Border( bottom: BorderSide(color: ShadcnTheme.border), ), ), 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('검색 초기화'), ), ], ], ), ); } // 이력 목록 + 상세 (데스크톱: 분할, 모바일: 목록만) if (isDesktop) { return Padding( padding: const EdgeInsets.all(16), child: Row( children: [ // 목록 Expanded( flex: 6, 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]); }, ), ), const SizedBox(width: 16), // 상세 패널 Expanded( flex: 5, child: _buildDetailPanel(), ), ], ), ); } // 모바일: 목록만, 항목 탭 시 상세 다이얼로그 표시 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()), ); } final item = _filteredHistories[index]; return GestureDetector( onTap: () { _selectHistory(item); _showDetailBottomSheet(context); }, child: _buildHistoryItem(item), ); }, ), ); }, ), ), // 푸터 Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: ShadcnTheme.card, borderRadius: const BorderRadius.only( bottomLeft: Radius.circular(12), bottomRight: Radius.circular(12), ), border: Border( top: BorderSide(color: ShadcnTheme.border), ), ), 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, ), ), ], ), ], ), ), ], ), ), ), ); } Widget _buildDetailPanel() { final h = _selectedHistory; return Container( decoration: BoxDecoration( color: ShadcnTheme.card, borderRadius: BorderRadius.circular(8), border: Border.all(color: ShadcnTheme.border), ), padding: const EdgeInsets.all(16), child: h == null ? Center( child: Text( '왼쪽에서 이력을 선택하세요', style: TextStyle(color: Colors.grey.shade600), ), ) : Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.receipt_long, color: Colors.grey.shade700), const SizedBox(width: 8), const Text('이력 상세', style: TextStyle(fontWeight: FontWeight.bold)), const Spacer(), if (h.id != null) IconButton( tooltip: 'ID 복사', icon: const Icon(Icons.copy, size: 18), onPressed: () { Clipboard.setData(ClipboardData(text: h.id.toString())); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('ID가 복사되었습니다')), ); }, ), ], ), const SizedBox(height: 12), _detailRow('거래일', _formatDate(h.transactedAt)), _detailRow('유형', _getTransactionTypeText(h.transactionType)), _detailRow('수량', h.quantity.toString()), _detailRow('창고', h.warehouseName ?? (h.warehousesId > 0 ? '#${h.warehousesId}' : '-')), _detailRow('장비', h.equipment?.serialNumber ?? h.equipmentSerial ?? '-'), _detailRow('회사', h.companies.isNotEmpty ? h.companies.map((e) => e['name'] ?? e['id']?.toString() ?? '-').join(', ') : '-'), const SizedBox(height: 8), const Text('비고', style: TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 4), Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: ShadcnTheme.background, borderRadius: BorderRadius.circular(6), border: Border.all(color: ShadcnTheme.border), ), child: Text(h.remark?.isNotEmpty == true ? h.remark! : '없음'), ), const SizedBox(height: 12), Divider(color: ShadcnTheme.border), const SizedBox(height: 8), _detailRow('생성일', _formatDate(h.createdAt)), _detailRow('수정일', _formatDate(h.updatedAt)), ], ), ); } Widget _detailRow(String label, String value) { return Padding( padding: const EdgeInsets.only(bottom: 8.0), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox(width: 80, child: Text(label, style: const TextStyle(fontWeight: FontWeight.w600))), const SizedBox(width: 8), Expanded(child: Text(value)), ], ), ); } void _showDetailBottomSheet(BuildContext context) { if (_selectedHistory == null) return; final h = _selectedHistory!; showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: ShadcnTheme.card, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(12)), ), builder: (context) { return Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Text('이력 상세', style: TextStyle(fontWeight: FontWeight.bold)), const Spacer(), IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context), ) ], ), const SizedBox(height: 12), _detailRow('거래일', _formatDate(h.transactedAt)), _detailRow('유형', _getTransactionTypeText(h.transactionType)), _detailRow('수량', h.quantity.toString()), _detailRow('창고', h.warehouseName ?? (h.warehousesId > 0 ? '#${h.warehousesId}' : '-')), _detailRow('장비', h.equipment?.serialNumber ?? h.equipmentSerial ?? '-'), _detailRow('회사', h.companies.isNotEmpty ? h.companies.map((e) => e['name'] ?? e['id']?.toString() ?? '-').join(', ') : '-'), const SizedBox(height: 8), const Text('비고', style: TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 4), Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: ShadcnTheme.background, borderRadius: BorderRadius.circular(6), border: Border.all(color: ShadcnTheme.border), ), child: Text(h.remark?.isNotEmpty == true ? h.remark! : '없음'), ), const SizedBox(height: 16), ], ), ), ); }, ); } }