장비 이력 상세보기 지원 및 상태/선택 동기화 개선
- EquipmentHistoryDialog: 좌우 분할 상세 패널(데스크톱) + 하단 시트(모바일) 추가 • 거래일/유형/수량/창고/장비/회사/비고/생성·수정일 표시 • 리스트 항목 선택 하이라이트, ID 복사 기능 - EquipmentListController: 이력 최신순 정렬 후 상태/일자 결정(오래된 순 가정 제거) - EquipmentList(ShadTable): 체크박스 선택 시 컨트롤러 선택집합과 동기화 - ResponseInterceptor: equipment-history는 transaction_type 로깅, 장비 목록은 status 로깅으로 혼동 제거
This commit is contained in:
@@ -11,12 +11,22 @@ class ResponseInterceptor extends Interceptor {
|
|||||||
debugPrint('[ResponseInterceptor] 상태 코드: ${response.statusCode}');
|
debugPrint('[ResponseInterceptor] 상태 코드: ${response.statusCode}');
|
||||||
debugPrint('[ResponseInterceptor] 응답 데이터 타입: ${response.data.runtimeType}');
|
debugPrint('[ResponseInterceptor] 응답 데이터 타입: ${response.data.runtimeType}');
|
||||||
|
|
||||||
// 장비 관련 API 응답 상세 로깅
|
// 장비/이력 관련 API 응답 상세 로깅 (혼동 방지용)
|
||||||
if (response.requestOptions.path.contains('equipment')) {
|
if (response.requestOptions.path.contains('equipment-history')) {
|
||||||
debugPrint('[ResponseInterceptor] 장비 API 응답 전체: ${response.data}');
|
|
||||||
if (response.data is List && (response.data as List).isNotEmpty) {
|
if (response.data is List && (response.data as List).isNotEmpty) {
|
||||||
final firstItem = (response.data as List).first;
|
final firstItem = (response.data as List).first;
|
||||||
debugPrint('[ResponseInterceptor] 첫 번째 장비 상태: ${firstItem['status']}');
|
debugPrint('[ResponseInterceptor] equipment-history 첫 항목 transaction_type: ${firstItem['transaction_type']}');
|
||||||
|
} else if (response.data is Map && (response.data as Map).containsKey('data')) {
|
||||||
|
final data = (response.data as Map)['data'];
|
||||||
|
if (data is List && data.isNotEmpty) {
|
||||||
|
debugPrint('[ResponseInterceptor] equipment-history 첫 항목 transaction_type: ${data.first['transaction_type']}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (response.requestOptions.path.contains('equipment')) {
|
||||||
|
debugPrint('[ResponseInterceptor] 장비 API 응답 도메인: ${response.requestOptions.path}');
|
||||||
|
if (response.data is List && (response.data as List).isNotEmpty) {
|
||||||
|
final firstItem = (response.data as List).first;
|
||||||
|
debugPrint('[ResponseInterceptor] 장비 첫 항목 status 필드: ${firstItem['status']}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,4 +122,4 @@ class ResponseInterceptor extends Interceptor {
|
|||||||
|
|
||||||
return !hasMetaKey;
|
return !hasMetaKey;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,11 +135,16 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
|
|||||||
try {
|
try {
|
||||||
final histories = await _historyService.getEquipmentHistoriesByEquipmentId(dto.id);
|
final histories = await _historyService.getEquipmentHistoriesByEquipmentId(dto.id);
|
||||||
if (histories.isNotEmpty) {
|
if (histories.isNotEmpty) {
|
||||||
// 최신 히스토리의 transaction_type 사용
|
// 최신 히스토리를 기준으로 상태 결정 (서버 정렬 보장 없음 → 클라이언트 정렬)
|
||||||
// 히스토리는 최신순으로 정렬되어 있다고 가정
|
histories.sort((a, b) {
|
||||||
status = histories.first.transactionType ?? 'I';
|
final aDate = a.transactedAt ?? a.createdAt;
|
||||||
transactionDate = histories.first.transactedAt ?? transactionDate;
|
final bDate = b.transactedAt ?? b.createdAt;
|
||||||
print('DEBUG [EquipmentListController] Equipment ${dto.id} status from history: $status');
|
return bDate.compareTo(aDate); // 내림차순: 최신 우선
|
||||||
|
});
|
||||||
|
final latest = histories.first;
|
||||||
|
status = latest.transactionType ?? 'I';
|
||||||
|
transactionDate = latest.transactedAt ?? transactionDate;
|
||||||
|
print('DEBUG [EquipmentListController] Equipment ${dto.id} latest status from history: $status @ ${latest.transactedAt ?? latest.createdAt}');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('DEBUG [EquipmentListController] Failed to get history for equipment ${dto.id}: $e');
|
print('DEBUG [EquipmentListController] Failed to get history for equipment ${dto.id}: $e');
|
||||||
|
|||||||
@@ -189,11 +189,20 @@ class _EquipmentListState extends State<EquipmentList> {
|
|||||||
onChanged: (checked) {
|
onChanged: (checked) {
|
||||||
setState(() {
|
setState(() {
|
||||||
if (id == null) return;
|
if (id == null) return;
|
||||||
|
// 로컬 선택 상태 업데이트
|
||||||
if (checked == true) {
|
if (checked == true) {
|
||||||
_selectedItems.add(id);
|
_selectedItems.add(id);
|
||||||
} else {
|
} else {
|
||||||
_selectedItems.remove(id);
|
_selectedItems.remove(id);
|
||||||
}
|
}
|
||||||
|
// 컨트롤러 선택 상태와 동기화
|
||||||
|
final equipmentKey = '${item.equipment.id}:${item.status}';
|
||||||
|
final isInController = _controller.selectedEquipmentIds.contains(equipmentKey);
|
||||||
|
if (checked == true && !isInController) {
|
||||||
|
_controller.toggleSelection(item);
|
||||||
|
} else if (checked == false && isInController) {
|
||||||
|
_controller.toggleSelection(item);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ class _EquipmentHistoryDialogState extends State<EquipmentHistoryDialog> {
|
|||||||
bool _hasMore = true;
|
bool _hasMore = true;
|
||||||
String _searchQuery = '';
|
String _searchQuery = '';
|
||||||
|
|
||||||
|
// 상세보기 상태
|
||||||
|
EquipmentHistoryDto? _selectedHistory;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -99,6 +102,12 @@ class _EquipmentHistoryDialogState extends State<EquipmentHistoryDialog> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _selectHistory(EquipmentHistoryDto history) {
|
||||||
|
setState(() {
|
||||||
|
_selectedHistory = history;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadHistory({
|
Future<void> _loadHistory({
|
||||||
bool isRefresh = false,
|
bool isRefresh = false,
|
||||||
bool isInitialLoad = false
|
bool isInitialLoad = false
|
||||||
@@ -208,13 +217,14 @@ class _EquipmentHistoryDialogState extends State<EquipmentHistoryDialog> {
|
|||||||
Widget _buildHistoryItem(EquipmentHistoryDto history) {
|
Widget _buildHistoryItem(EquipmentHistoryDto history) {
|
||||||
final typeColor = _getTransactionTypeColor(history.transactionType);
|
final typeColor = _getTransactionTypeColor(history.transactionType);
|
||||||
final typeText = _getTransactionTypeText(history.transactionType);
|
final typeText = _getTransactionTypeText(history.transactionType);
|
||||||
|
final isSelected = _selectedHistory?.id != null && _selectedHistory?.id == history.id;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: ShadcnTheme.card,
|
color: isSelected ? ShadcnTheme.cardHover : ShadcnTheme.card,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(color: ShadcnTheme.border),
|
border: Border.all(color: isSelected ? ShadcnTheme.primary : ShadcnTheme.border),
|
||||||
boxShadow: ShadcnTheme.shadowSm,
|
boxShadow: ShadcnTheme.shadowSm,
|
||||||
),
|
),
|
||||||
child: Material(
|
child: Material(
|
||||||
@@ -222,7 +232,7 @@ class _EquipmentHistoryDialogState extends State<EquipmentHistoryDialog> {
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
onTap: () {}, // 클릭 효과를 위한 빈 핸들러
|
onTap: () => _selectHistory(history),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -543,7 +553,40 @@ class _EquipmentHistoryDialogState extends State<EquipmentHistoryDialog> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이력 목록
|
// 이력 목록 + 상세 (데스크톱: 분할, 모바일: 목록만)
|
||||||
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
@@ -553,12 +596,17 @@ class _EquipmentHistoryDialogState extends State<EquipmentHistoryDialog> {
|
|||||||
if (index == _filteredHistories.length) {
|
if (index == _filteredHistories.length) {
|
||||||
return const Padding(
|
return const Padding(
|
||||||
padding: EdgeInsets.all(16.0),
|
padding: EdgeInsets.all(16.0),
|
||||||
child: Center(
|
child: Center(child: CircularProgressIndicator()),
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return _buildHistoryItem(_filteredHistories[index]);
|
final item = _filteredHistories[index];
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
_selectHistory(item);
|
||||||
|
_showDetailBottomSheet(context);
|
||||||
|
},
|
||||||
|
child: _buildHistoryItem(item),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -617,4 +665,146 @@ class _EquipmentHistoryDialogState extends State<EquipmentHistoryDialog> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user