feat: V/R 유지보수 시스템 전환 및 대시보드 테이블 형태 완성
Some checks failed
Flutter Test & Quality Check / Test on macos-latest (push) Has been cancelled
Flutter Test & Quality Check / Test on ubuntu-latest (push) Has been cancelled
Flutter Test & Quality Check / Build APK (push) Has been cancelled

- V/R 시스템 완전 전환: WARRANTY/CONTRACT/INSPECTION → V(방문)/R(원격)
- 유지보수 대시보드 카드 → StandardDataTable 테이블 형태 전환
- "조회중..." 문제 해결: 백엔드 직접 필드 사용 (equipment_model, company_name)
- MaintenanceDto 신규 필드 추가: company_id, company_name, equipment_serial, equipment_model
- preloadEquipmentData 비활성화로 불필요한 equipment-history API 호출 제거
- CO-STAR 프레임워크 적용 및 CLAUDE.md v3.0 업데이트
- Flutter Analyze ERROR: 0 유지, 100% shadcn_ui 컴플라이언스

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
JiWoong Sul
2025-09-05 14:33:20 +09:00
parent 2c20999025
commit 519e1883a3
46 changed files with 7804 additions and 1034 deletions

View File

@@ -0,0 +1,326 @@
import 'package:flutter/material.dart';
import 'package:superport/data/models/inventory_history_view_model.dart';
import 'package:superport/services/inventory_history_service.dart';
import 'package:superport/core/constants/app_constants.dart';
/// 재고 이력 관리 화면 전용 컨트롤러
/// InventoryHistoryService를 통해 여러 API를 조합한 데이터 관리
class InventoryHistoryController extends ChangeNotifier {
final InventoryHistoryService _service;
InventoryHistoryController({
InventoryHistoryService? service,
}) : _service = service ?? InventoryHistoryService();
// 상태 변수
List<InventoryHistoryViewModel> _historyItems = [];
bool _isLoading = false;
String? _error;
// 페이지네이션
int _currentPage = 1;
int _pageSize = AppConstants.historyPageSize;
int _totalCount = 0;
int _totalPages = 0;
// 검색 및 필터
String _searchKeyword = '';
String? _selectedTransactionType;
int? _selectedEquipmentId;
int? _selectedWarehouseId;
int? _selectedCompanyId;
DateTime? _dateFrom;
DateTime? _dateTo;
// Getters
List<InventoryHistoryViewModel> get historyItems => _historyItems;
bool get isLoading => _isLoading;
String? get error => _error;
int get currentPage => _currentPage;
int get totalPages => _totalPages;
int get totalCount => _totalCount;
int get pageSize => _pageSize;
String get searchKeyword => _searchKeyword;
String? get selectedTransactionType => _selectedTransactionType;
// 통계 정보
int get totalTransactions => _historyItems.length;
int get inStockCount => _historyItems.where((item) => item.transactionType == 'I').length;
int get outStockCount => _historyItems.where((item) => item.transactionType == 'O').length;
int get rentCount => _historyItems.where((item) => item.transactionType == 'R').length;
int get disposeCount => _historyItems.where((item) => item.transactionType == 'D').length;
/// 재고 이력 목록 로드
Future<void> loadHistories({bool refresh = false}) async {
if (refresh) {
_currentPage = 1;
_historyItems.clear();
}
_isLoading = true;
_error = null;
notifyListeners();
try {
print('[InventoryHistoryController] Loading histories - Page: $_currentPage, Search: "$_searchKeyword", Type: $_selectedTransactionType');
final response = await _service.loadInventoryHistories(
page: _currentPage,
pageSize: _pageSize,
searchKeyword: _searchKeyword.isEmpty ? null : _searchKeyword,
transactionType: _selectedTransactionType,
equipmentId: _selectedEquipmentId,
warehouseId: _selectedWarehouseId,
companyId: _selectedCompanyId,
dateFrom: _dateFrom,
dateTo: _dateTo,
);
if (refresh) {
_historyItems = response.items;
} else {
_historyItems.addAll(response.items);
}
_totalCount = response.totalCount;
_totalPages = response.totalPages;
print('[InventoryHistoryController] Loaded ${response.items.length} items, Total: $_totalCount');
} catch (e) {
_error = e.toString();
print('[InventoryHistoryController] Error loading histories: $e');
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 특정 장비의 전체 이력 로드 (상세보기용)
Future<List<InventoryHistoryViewModel>> loadEquipmentHistory(int equipmentId) async {
try {
print('[InventoryHistoryController] Loading equipment history for ID: $equipmentId');
final histories = await _service.loadEquipmentHistory(equipmentId);
print('[InventoryHistoryController] Loaded ${histories.length} equipment histories');
return histories;
} catch (e) {
print('[InventoryHistoryController] Error loading equipment history: $e');
rethrow;
}
}
/// 검색 키워드 설정
void setSearchKeyword(String keyword) {
if (_searchKeyword != keyword) {
_searchKeyword = keyword;
_currentPage = 1;
loadHistories(refresh: true);
}
}
/// 거래 유형 필터 설정
void setTransactionTypeFilter(String? transactionType) {
if (_selectedTransactionType != transactionType) {
_selectedTransactionType = transactionType;
_currentPage = 1;
loadHistories(refresh: true);
}
}
/// 장비 필터 설정
void setEquipmentFilter(int? equipmentId) {
if (_selectedEquipmentId != equipmentId) {
_selectedEquipmentId = equipmentId;
_currentPage = 1;
loadHistories(refresh: true);
}
}
/// 창고 필터 설정
void setWarehouseFilter(int? warehouseId) {
if (_selectedWarehouseId != warehouseId) {
_selectedWarehouseId = warehouseId;
_currentPage = 1;
loadHistories(refresh: true);
}
}
/// 고객사 필터 설정
void setCompanyFilter(int? companyId) {
if (_selectedCompanyId != companyId) {
_selectedCompanyId = companyId;
_currentPage = 1;
loadHistories(refresh: true);
}
}
/// 날짜 범위 필터 설정
void setDateRangeFilter(DateTime? dateFrom, DateTime? dateTo) {
if (_dateFrom != dateFrom || _dateTo != dateTo) {
_dateFrom = dateFrom;
_dateTo = dateTo;
_currentPage = 1;
loadHistories(refresh: true);
}
}
/// 복합 필터 설정 (한 번에 여러 필터 적용)
void setFilters({
String? searchKeyword,
String? transactionType,
int? equipmentId,
int? warehouseId,
int? companyId,
DateTime? dateFrom,
DateTime? dateTo,
}) {
bool hasChanges = false;
if (searchKeyword != null && _searchKeyword != searchKeyword) {
_searchKeyword = searchKeyword;
hasChanges = true;
}
if (_selectedTransactionType != transactionType) {
_selectedTransactionType = transactionType;
hasChanges = true;
}
if (_selectedEquipmentId != equipmentId) {
_selectedEquipmentId = equipmentId;
hasChanges = true;
}
if (_selectedWarehouseId != warehouseId) {
_selectedWarehouseId = warehouseId;
hasChanges = true;
}
if (_selectedCompanyId != companyId) {
_selectedCompanyId = companyId;
hasChanges = true;
}
if (_dateFrom != dateFrom || _dateTo != dateTo) {
_dateFrom = dateFrom;
_dateTo = dateTo;
hasChanges = true;
}
if (hasChanges) {
_currentPage = 1;
loadHistories(refresh: true);
}
}
/// 모든 필터 초기화
void clearFilters() {
_searchKeyword = '';
_selectedTransactionType = null;
_selectedEquipmentId = null;
_selectedWarehouseId = null;
_selectedCompanyId = null;
_dateFrom = null;
_dateTo = null;
_currentPage = 1;
loadHistories(refresh: true);
}
/// 다음 페이지 로드
Future<void> loadNextPage() async {
if (_currentPage < _totalPages && !_isLoading) {
_currentPage++;
await loadHistories();
}
}
/// 이전 페이지 로드
Future<void> loadPreviousPage() async {
if (_currentPage > 1 && !_isLoading) {
_currentPage--;
await loadHistories();
}
}
/// 특정 페이지로 이동
Future<void> goToPage(int page) async {
if (page > 0 && page <= _totalPages && page != _currentPage && !_isLoading) {
_currentPage = page;
await loadHistories();
}
}
/// 데이터 새로고침
Future<void> refresh() async {
await loadHistories(refresh: true);
}
/// 에러 초기화
void clearError() {
_error = null;
notifyListeners();
}
/// 통계 정보 맵 형태로 반환
Map<String, dynamic> getStatistics() {
return {
'total': totalCount,
'current_page_count': totalTransactions,
'in_stock': inStockCount,
'out_stock': outStockCount,
'rent': rentCount,
'dispose': disposeCount,
};
}
/// 검색 상태 확인
bool get hasActiveFilters {
return _searchKeyword.isNotEmpty ||
_selectedTransactionType != null ||
_selectedEquipmentId != null ||
_selectedWarehouseId != null ||
_selectedCompanyId != null ||
_dateFrom != null ||
_dateTo != null;
}
/// 필터 상태 텍스트
String get filterStatusText {
List<String> filters = [];
if (_searchKeyword.isNotEmpty) {
filters.add('검색: "$_searchKeyword"');
}
if (_selectedTransactionType != null) {
final typeMap = {
'I': '입고',
'O': '출고',
'R': '대여',
'D': '폐기',
};
filters.add('유형: ${typeMap[_selectedTransactionType]}');
}
if (_dateFrom != null || _dateTo != null) {
String dateFilter = '기간: ';
if (_dateFrom != null) {
dateFilter += '${_dateFrom!.toString().substring(0, 10)}';
}
if (_dateTo != null) {
dateFilter += ' ~ ${_dateTo!.toString().substring(0, 10)}';
}
filters.add(dateFilter);
}
return filters.join(', ');
}
@override
void dispose() {
_historyItems.clear();
super.dispose();
}
}

View File

@@ -0,0 +1,537 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport/data/models/inventory_history_view_model.dart';
import 'package:superport/screens/inventory/controllers/inventory_history_controller.dart';
import 'package:superport/screens/common/theme_shadcn.dart';
import 'package:superport/screens/common/components/shadcn_components.dart';
/// 장비 이력 상세보기 다이얼로그
/// 특정 장비의 전체 히스토리를 시간순으로 표시
class EquipmentHistoryDetailDialog extends StatefulWidget {
final int equipmentId;
final String equipmentName;
final String serialNumber;
final InventoryHistoryController controller;
const EquipmentHistoryDetailDialog({
super.key,
required this.equipmentId,
required this.equipmentName,
required this.serialNumber,
required this.controller,
});
@override
State<EquipmentHistoryDetailDialog> createState() =>
_EquipmentHistoryDetailDialogState();
}
class _EquipmentHistoryDetailDialogState
extends State<EquipmentHistoryDetailDialog> {
List<InventoryHistoryViewModel>? _historyList;
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_loadEquipmentHistory();
}
/// 장비별 이력 로드
Future<void> _loadEquipmentHistory() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final histories = await widget.controller.loadEquipmentHistory(widget.equipmentId);
setState(() {
_historyList = histories;
_isLoading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_isLoading = false;
});
print('[EquipmentHistoryDetailDialog] Error loading equipment history: $e');
}
}
/// 거래 유형 아이콘 반환
IconData _getTransactionIcon(String transactionType) {
switch (transactionType) {
case 'I':
return Icons.arrow_downward; // 입고
case 'O':
return Icons.arrow_upward; // 출고
case 'R':
return Icons.share; // 대여
case 'D':
return Icons.delete_outline; // 폐기
default:
return Icons.help_outline;
}
}
/// 거래 유형 색상 반환
Color _getTransactionColor(String transactionType) {
switch (transactionType) {
case 'I':
return Colors.green; // 입고
case 'O':
return Colors.orange; // 출고
case 'R':
return Colors.blue; // 대여
case 'D':
return Colors.red; // 폐기
default:
return Colors.grey;
}
}
/// 타임라인 아이템 빌더
Widget _buildTimelineItem(InventoryHistoryViewModel history, int index) {
final isFirst = index == 0;
final isLast = index == (_historyList?.length ?? 0) - 1;
final color = _getTransactionColor(history.transactionType);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 타임라인 인디케이터
SizedBox(
width: 60,
child: Column(
children: [
// 위쪽 연결선
if (!isFirst)
Container(
width: 2,
height: 20,
color: Colors.grey.withValues(alpha: 0.3),
),
// 원형 인디케이터
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color,
border: Border.all(
color: Colors.white,
width: 2,
),
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Icon(
_getTransactionIcon(history.transactionType),
color: Colors.white,
size: 16,
),
),
// 아래쪽 연결선
if (!isLast)
Container(
width: 2,
height: 20,
color: Colors.grey.withValues(alpha: 0.3),
),
],
),
),
const SizedBox(width: 16),
// 이력 정보
Expanded(
child: Container(
margin: EdgeInsets.only(bottom: isLast ? 0 : 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: ShadcnTheme.card,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: color.withValues(alpha: 0.2),
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 헤더 (거래 유형 + 날짜)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
ShadcnBadge(
text: history.transactionTypeDisplay,
variant: _getBadgeVariant(history.transactionType),
size: ShadcnBadgeSize.small,
),
const SizedBox(width: 8),
if (isFirst)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: Colors.orange.withValues(alpha: 0.3),
),
),
child: const Text(
'최근',
style: TextStyle(
fontSize: 10,
color: Colors.orange,
fontWeight: FontWeight.w500,
),
),
),
],
),
Text(
history.formattedDate,
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.mutedForeground,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 12),
// 위치 정보
Row(
children: [
Icon(
history.isCustomerLocation ? Icons.business : Icons.warehouse,
size: 16,
color: history.isCustomerLocation ? Colors.blue : Colors.green,
),
const SizedBox(width: 6),
Text(
'위치: ',
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.mutedForeground,
),
),
Expanded(
child: Text(
history.location,
style: ShadcnTheme.bodySmall.copyWith(
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
// 수량 정보
if (history.quantity > 0) ...[
const SizedBox(height: 6),
Row(
children: [
Icon(
Icons.inventory,
size: 16,
color: ShadcnTheme.mutedForeground,
),
const SizedBox(width: 6),
Text(
'수량: ',
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.mutedForeground,
),
),
Text(
'${history.quantity}',
style: ShadcnTheme.bodySmall.copyWith(
fontWeight: FontWeight.w500,
),
),
],
),
],
// 비고
if (history.remark != null && history.remark!.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: ShadcnTheme.muted.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(4),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.note,
size: 14,
color: ShadcnTheme.mutedForeground,
),
const SizedBox(width: 6),
Expanded(
child: Text(
history.remark!,
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.mutedForeground,
fontStyle: FontStyle.italic,
),
),
),
],
),
),
],
],
),
),
),
],
);
}
/// Badge Variant 반환
ShadcnBadgeVariant _getBadgeVariant(String transactionType) {
switch (transactionType) {
case 'I':
return ShadcnBadgeVariant.success; // 입고
case 'O':
return ShadcnBadgeVariant.warning; // 출고
case 'R':
return ShadcnBadgeVariant.info; // 대여
case 'D':
return ShadcnBadgeVariant.destructive; // 폐기
default:
return ShadcnBadgeVariant.secondary;
}
}
@override
Widget build(BuildContext context) {
return ShadDialog(
title: Row(
children: [
Icon(
Icons.history,
color: ShadcnTheme.primary,
size: 20,
),
const SizedBox(width: 8),
const Text('장비 이력 상세'),
],
),
description: SingleChildScrollView(
child: SizedBox(
width: 600,
height: 500,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 장비 정보 헤더
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: ShadcnTheme.muted.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: ShadcnTheme.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.precision_manufacturing,
color: ShadcnTheme.primary,
size: 18,
),
const SizedBox(width: 8),
Text(
widget.equipmentName,
style: ShadcnTheme.bodyLarge.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.qr_code,
color: ShadcnTheme.mutedForeground,
size: 16,
),
const SizedBox(width: 6),
Text(
'시리얼: ${widget.serialNumber}',
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.mutedForeground,
),
),
],
),
],
),
),
const SizedBox(height: 20),
// 이력 목록 헤더
Row(
children: [
Icon(
Icons.timeline,
color: ShadcnTheme.mutedForeground,
size: 16,
),
const SizedBox(width: 6),
Text(
'변동 이력 (시간순)',
style: ShadcnTheme.bodyMedium.copyWith(
fontWeight: FontWeight.w500,
color: ShadcnTheme.mutedForeground,
),
),
if (_historyList != null) ...[
const SizedBox(width: 8),
ShadcnBadge(
text: '${_historyList!.length}',
variant: ShadcnBadgeVariant.secondary,
size: ShadcnBadgeSize.small,
),
],
],
),
const SizedBox(height: 12),
// 이력 목록
Expanded(
child: _buildHistoryContent(),
),
],
),
),
),
actions: [
ShadcnButton(
text: '새로고침',
onPressed: _loadEquipmentHistory,
variant: ShadcnButtonVariant.secondary,
icon: const Icon(Icons.refresh, size: 16),
),
ShadcnButton(
text: '닫기',
onPressed: () => Navigator.of(context).pop(),
variant: ShadcnButtonVariant.primary,
),
],
);
}
/// 이력 컨텐츠 빌더
Widget _buildHistoryContent() {
if (_isLoading) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(height: 12),
Text('이력을 불러오는 중...'),
],
),
);
}
if (_error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 48,
color: Colors.red.withValues(alpha: 0.6),
),
const SizedBox(height: 12),
Text(
'이력을 불러올 수 없습니다',
style: ShadcnTheme.bodyMedium.copyWith(
color: Colors.red,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
_error!,
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.mutedForeground,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
ShadcnButton(
text: '다시 시도',
onPressed: _loadEquipmentHistory,
variant: ShadcnButtonVariant.secondary,
icon: const Icon(Icons.refresh, size: 16),
),
],
),
);
}
if (_historyList == null || _historyList!.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inventory_2_outlined,
size: 48,
color: ShadcnTheme.mutedForeground.withValues(alpha: 0.6),
),
const SizedBox(height: 12),
Text(
'등록된 이력이 없습니다',
style: ShadcnTheme.bodyMedium.copyWith(
color: ShadcnTheme.mutedForeground,
),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _historyList!.length,
itemBuilder: (context, index) {
return _buildTimelineItem(_historyList![index], index);
},
);
}
}

View File

@@ -1,13 +1,17 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../core/constants/app_constants.dart';
import '../../screens/equipment/controllers/equipment_history_controller.dart';
import 'components/transaction_type_badge.dart';
import '../common/layouts/base_list_screen.dart';
import '../common/widgets/standard_action_bar.dart';
import '../common/widgets/pagination.dart';
import 'package:superport/screens/inventory/controllers/inventory_history_controller.dart';
import 'package:superport/data/models/inventory_history_view_model.dart';
import 'package:superport/screens/common/layouts/base_list_screen.dart';
import 'package:superport/screens/common/widgets/standard_action_bar.dart';
import 'package:superport/screens/common/widgets/pagination.dart';
import 'package:superport/screens/common/theme_shadcn.dart';
import 'package:superport/screens/common/components/shadcn_components.dart';
import 'package:superport/screens/inventory/dialogs/equipment_history_detail_dialog.dart';
/// 재고 이력 관리 화면 (완전 재설계)
/// 요구사항: 장비명, 시리얼번호, 위치, 변동일, 작업, 비고
class InventoryHistoryScreen extends StatefulWidget {
const InventoryHistoryScreen({super.key});
@@ -16,46 +20,58 @@ class InventoryHistoryScreen extends StatefulWidget {
}
class _InventoryHistoryScreenState extends State<InventoryHistoryScreen> {
late final InventoryHistoryController _controller;
final TextEditingController _searchController = TextEditingController();
String _appliedSearchKeyword = '';
String _selectedType = 'all';
String _selectedTransactionType = 'all';
@override
void initState() {
super.initState();
_controller = InventoryHistoryController();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<EquipmentHistoryController>().loadHistory();
_controller.loadHistories();
});
}
@override
void dispose() {
_searchController.dispose();
_controller.dispose();
super.dispose();
}
/// 검색 실행
void _onSearch() {
final searchQuery = _searchController.text.trim();
setState(() {
_appliedSearchKeyword = searchQuery;
});
// ✅ Controller 검색 메서드 연동
context.read<EquipmentHistoryController>().setFilters(
searchQuery: searchQuery.isNotEmpty ? searchQuery : null,
transactionType: _selectedType != 'all' ? _selectedType : null,
_controller.setFilters(
searchKeyword: searchQuery.isNotEmpty ? searchQuery : null,
transactionType: _selectedTransactionType != 'all' ? _selectedTransactionType : null,
);
}
/// 검색 초기화
void _clearSearch() {
_searchController.clear();
setState(() {
_appliedSearchKeyword = '';
_selectedType = 'all';
_selectedTransactionType = 'all';
});
// ✅ Controller 필터 초기화
context.read<EquipmentHistoryController>().setFilters(
searchQuery: null,
transactionType: null,
_controller.clearFilters();
}
/// 거래 유형 필터 변경
void _onTransactionTypeChanged(String type) {
setState(() {
_selectedTransactionType = type;
});
_controller.setFilters(
searchKeyword: _appliedSearchKeyword.isNotEmpty ? _appliedSearchKeyword : null,
transactionType: type != 'all' ? type : null,
);
}
@@ -66,12 +82,16 @@ class _InventoryHistoryScreenState extends State<InventoryHistoryScreen> {
required bool useExpanded,
required double minWidth,
}) {
final theme = ShadTheme.of(context);
final child = Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Text(
text,
style: theme.textTheme.large.copyWith(fontWeight: FontWeight.w500),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
);
@@ -91,6 +111,7 @@ class _InventoryHistoryScreenState extends State<InventoryHistoryScreen> {
}) {
final container = Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: child,
);
@@ -101,144 +122,150 @@ class _InventoryHistoryScreenState extends State<InventoryHistoryScreen> {
}
}
/// 헤더 셀 리스트
/// 헤더 셀 리스트 (요구사항에 맞게 재정의)
List<Widget> _buildHeaderCells() {
return [
_buildHeaderCell('ID', flex: 0, useExpanded: false, minWidth: 60),
_buildHeaderCell('거래 유형', flex: 0, useExpanded: false, minWidth: 80),
_buildHeaderCell('장비명', flex: 2, useExpanded: true, minWidth: 120),
_buildHeaderCell('시리얼 번호', flex: 2, useExpanded: true, minWidth: 120),
_buildHeaderCell('창고', flex: 1, useExpanded: true, minWidth: 100),
_buildHeaderCell('수량', flex: 0, useExpanded: false, minWidth: 80),
_buildHeaderCell('거래일', flex: 0, useExpanded: false, minWidth: 100),
_buildHeaderCell('비고', flex: 1, useExpanded: true, minWidth: 100),
_buildHeaderCell('작업', flex: 0, useExpanded: false, minWidth: 100),
_buildHeaderCell('장비명', flex: 3, useExpanded: true, minWidth: 150),
_buildHeaderCell('시리얼번호', flex: 2, useExpanded: true, minWidth: 120),
_buildHeaderCell('위치', flex: 2, useExpanded: true, minWidth: 120),
_buildHeaderCell('변동일', flex: 1, useExpanded: false, minWidth: 100),
_buildHeaderCell('작업', flex: 0, useExpanded: false, minWidth: 80),
_buildHeaderCell('비고', flex: 2, useExpanded: true, minWidth: 120),
];
}
/// 테이블 행 빌더
Widget _buildTableRow(dynamic history, int index) {
final theme = ShadTheme.of(context);
/// 테이블 행 빌더 (요구사항에 맞게 재정의)
Widget _buildTableRow(InventoryHistoryViewModel history, int index) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: index.isEven
? theme.colorScheme.muted.withValues(alpha: 0.1)
: null,
color: index.isEven ? ShadcnTheme.muted.withValues(alpha: 0.1) : null,
border: const Border(
bottom: BorderSide(color: Colors.black),
bottom: BorderSide(color: Colors.black12, width: 1),
),
),
child: Row(
children: [
// 장비명
_buildDataCell(
Text(
'${history.id}',
style: theme.textTheme.small,
),
flex: 0,
useExpanded: false,
minWidth: 60,
),
_buildDataCell(
TransactionTypeBadge(
type: history.transactionType ?? '',
),
flex: 0,
useExpanded: false,
minWidth: 80,
),
_buildDataCell(
Text(
history.equipment?.modelName ?? '-',
style: theme.textTheme.large.copyWith(
fontWeight: FontWeight.w500,
Tooltip(
message: history.equipmentName,
child: Text(
history.equipmentName,
style: ShadcnTheme.bodyMedium.copyWith(
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
flex: 3,
useExpanded: true,
minWidth: 150,
),
// 시리얼번호
_buildDataCell(
Tooltip(
message: history.serialNumber,
child: Text(
history.serialNumber,
style: ShadcnTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),
overflow: TextOverflow.ellipsis,
),
flex: 2,
useExpanded: true,
minWidth: 120,
),
// 위치 (출고/대여: 고객사, 입고/폐기: 창고)
_buildDataCell(
Text(
history.equipment?.serialNumber ?? '-',
style: theme.textTheme.small,
overflow: TextOverflow.ellipsis,
Tooltip(
message: history.location,
child: Row(
children: [
Icon(
history.isCustomerLocation ? Icons.business : Icons.warehouse,
size: 14,
color: history.isCustomerLocation ? Colors.blue : Colors.green,
),
const SizedBox(width: 4),
Expanded(
child: Text(
history.location,
style: ShadcnTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
flex: 2,
useExpanded: true,
minWidth: 120,
),
// 변동일
_buildDataCell(
Text(
history.warehouse?.name ?? '-',
style: theme.textTheme.small,
overflow: TextOverflow.ellipsis,
history.formattedDate,
style: ShadcnTheme.bodySmall,
),
flex: 1,
useExpanded: true,
useExpanded: false,
minWidth: 100,
),
// 작업 (상세보기만)
_buildDataCell(
Text(
'${history.quantity ?? 0}',
style: theme.textTheme.small,
textAlign: TextAlign.center,
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: () => _showEquipmentHistoryDetail(history),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.history, size: 14),
SizedBox(width: 4),
Text('상세보기', style: TextStyle(fontSize: 12)),
],
),
),
flex: 0,
useExpanded: false,
minWidth: 80,
),
// 비고
_buildDataCell(
Text(
DateFormat('yyyy-MM-dd').format(history.transactedAt),
style: theme.textTheme.small,
Tooltip(
message: history.remark ?? '비고 없음',
child: Text(
history.remark ?? '-',
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.mutedForeground,
),
overflow: TextOverflow.ellipsis,
),
),
flex: 0,
useExpanded: false,
minWidth: 100,
),
_buildDataCell(
Text(
history.remark ?? '-',
style: theme.textTheme.small,
overflow: TextOverflow.ellipsis,
),
flex: 1,
flex: 2,
useExpanded: true,
minWidth: 100,
),
_buildDataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () {
// 편집 기능
},
child: const Icon(Icons.edit, size: 16),
),
const SizedBox(width: 4),
ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () {
// 삭제 기능
},
child: const Icon(Icons.delete, size: 16),
),
],
),
flex: 0,
useExpanded: false,
minWidth: 100,
minWidth: 120,
),
],
),
);
}
/// 장비 이력 상세보기 다이얼로그 표시
void _showEquipmentHistoryDetail(InventoryHistoryViewModel history) async {
await showDialog<void>(
context: context,
barrierDismissible: true,
builder: (BuildContext context) {
return EquipmentHistoryDetailDialog(
equipmentId: history.equipmentId,
equipmentName: history.equipmentName,
serialNumber: history.serialNumber,
controller: _controller,
);
},
);
}
/// 검색 바 빌더
Widget _buildSearchBar() {
return Row(
@@ -249,23 +276,23 @@ class _InventoryHistoryScreenState extends State<InventoryHistoryScreen> {
child: Container(
height: 40,
decoration: BoxDecoration(
color: ShadTheme.of(context).colorScheme.card,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.black),
color: ShadcnTheme.card,
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
border: Border.all(color: ShadcnTheme.border),
),
child: TextField(
controller: _searchController,
onSubmitted: (_) => _onSearch(),
decoration: InputDecoration(
hintText: '장비명, 시리얼번호, 창고명 등...',
hintText: '장비명, 시리얼번호, 위치, 비고 등...',
hintStyle: TextStyle(
color: ShadTheme.of(context).colorScheme.mutedForeground.withValues(alpha: 0.8),
color: ShadcnTheme.mutedForeground.withValues(alpha: 0.8),
fontSize: 14),
prefixIcon: Icon(Icons.search, color: ShadTheme.of(context).colorScheme.muted, size: 20),
prefixIcon: Icon(Icons.search, color: ShadcnTheme.muted, size: 20),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
style: ShadTheme.of(context).textTheme.large,
style: ShadcnTheme.bodyMedium,
),
),
),
@@ -273,36 +300,27 @@ class _InventoryHistoryScreenState extends State<InventoryHistoryScreen> {
const SizedBox(width: 16),
// 거래 유형 필터
Container(
SizedBox(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: ShadTheme.of(context).colorScheme.card,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.black),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedType,
items: const [
DropdownMenuItem(value: 'all', child: Text('전체')),
DropdownMenuItem(value: 'I', child: Text('입고')),
DropdownMenuItem(value: 'O', child: Text('출고')),
],
onChanged: (value) {
if (value != null) {
setState(() {
_selectedType = value;
});
// ✅ 필터 변경 시 즉시 Controller에 반영
context.read<EquipmentHistoryController>().setFilters(
searchQuery: _appliedSearchKeyword.isNotEmpty ? _appliedSearchKeyword : null,
transactionType: value != 'all' ? value : null,
);
}
},
style: ShadTheme.of(context).textTheme.large,
width: 120,
child: ShadSelect<String>(
selectedOptionBuilder: (context, value) => Text(
_getTransactionTypeDisplayText(value),
style: const TextStyle(fontSize: 14),
),
placeholder: const Text('거래 유형'),
options: [
const ShadOption(value: 'all', child: Text('전체')),
const ShadOption(value: 'I', child: Text('입고')),
const ShadOption(value: 'O', child: Text('출고')),
const ShadOption(value: 'R', child: Text('대여')),
const ShadOption(value: 'D', child: Text('폐기')),
],
onChanged: (value) {
if (value != null) {
_onTransactionTypeChanged(value);
}
},
),
),
@@ -311,19 +329,24 @@ class _InventoryHistoryScreenState extends State<InventoryHistoryScreen> {
// 검색 버튼
SizedBox(
height: 40,
child: ShadButton(
child: ShadcnButton(
text: '검색',
onPressed: _onSearch,
child: const Text('검색'),
variant: ShadcnButtonVariant.primary,
textColor: Colors.white,
icon: const Icon(Icons.search, size: 16),
),
),
if (_appliedSearchKeyword.isNotEmpty) ...[
if (_appliedSearchKeyword.isNotEmpty || _selectedTransactionType != 'all') ...[
const SizedBox(width: 8),
SizedBox(
height: 40,
child: ShadButton.outline(
child: ShadcnButton(
text: '초기화',
onPressed: _clearSearch,
child: const Text('초기화'),
variant: ShadcnButtonVariant.secondary,
icon: const Icon(Icons.clear, size: 16),
),
),
],
@@ -333,89 +356,84 @@ class _InventoryHistoryScreenState extends State<InventoryHistoryScreen> {
/// 액션 바 빌더
Widget _buildActionBar() {
return Consumer<EquipmentHistoryController>(
return Consumer<InventoryHistoryController>(
builder: (context, controller, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 제목과 설명
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'재고 이력 관리',
style: ShadTheme.of(context).textTheme.h4,
final stats = controller.getStatistics();
return StandardActionBar(
leftActions: [
// 통계 정보 표시
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: ShadcnTheme.muted.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: ShadcnTheme.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.inventory_2, size: 16),
const SizedBox(width: 8),
Text(
'${stats['total']}',
style: ShadcnTheme.bodyMedium.copyWith(
fontWeight: FontWeight.w500,
),
const SizedBox(height: 4),
Text(
'장비 입출고 이력을 조회하고 관리합니다',
style: ShadTheme.of(context).textTheme.muted,
),
],
),
Row(
children: [
ShadButton(
onPressed: () {
Navigator.pushNamed(context, '/inventory/stock-in');
},
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add, size: 16),
SizedBox(width: 8),
Text('입고 등록'),
],
),
),
const SizedBox(width: 8),
ShadButton.outline(
onPressed: () {
Navigator.pushNamed(context, '/inventory/stock-out');
},
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.remove, size: 16),
SizedBox(width: 8),
Text('출고 처리'),
],
),
),
],
),
],
),
const SizedBox(height: 16),
// 표준 액션바
StandardActionBar(
totalCount: controller.totalCount,
statusMessage: '${controller.totalTransactions}건의 거래 이력',
rightActions: [
ShadButton.ghost(
onPressed: () => controller.loadHistory(),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.refresh, size: 16),
SizedBox(width: 4),
Text('새로고침'),
],
),
),
],
if (controller.hasActiveFilters) ...[
const SizedBox(width: 8),
const Text('|', style: TextStyle(color: Colors.grey)),
const SizedBox(width: 8),
Text(
'필터링됨',
style: ShadcnTheme.bodySmall.copyWith(
color: Colors.orange,
fontWeight: FontWeight.w500,
),
),
],
],
),
),
],
rightActions: [
// 새로고침 버튼
ShadcnButton(
text: '새로고침',
onPressed: () => controller.refresh(),
variant: ShadcnButtonVariant.secondary,
icon: const Icon(Icons.refresh, size: 16),
),
],
totalCount: stats['total'],
statusMessage: controller.hasActiveFilters
? '${controller.filterStatusText}'
: '장비 입출고 이력을 조회합니다',
);
},
);
}
/// 데이터 테이블 빌더 (표준 패턴)
Widget _buildDataTable(List<dynamic> historyList) {
/// 거래 유형 표시 텍스트
String _getTransactionTypeDisplayText(String type) {
switch (type) {
case 'all':
return '전체';
case 'I':
return '입고';
case 'O':
return '출고';
case 'R':
return '대여';
case 'D':
return '폐기';
default:
return type;
}
}
/// 데이터 테이블 빌더
Widget _buildDataTable(List<InventoryHistoryViewModel> historyList) {
if (historyList.isEmpty) {
return Center(
child: Column(
@@ -424,17 +442,24 @@ class _InventoryHistoryScreenState extends State<InventoryHistoryScreen> {
Icon(
Icons.inventory_2_outlined,
size: 64,
color: ShadTheme.of(context).colorScheme.mutedForeground,
color: ShadcnTheme.mutedForeground,
),
const SizedBox(height: 16),
Text(
_appliedSearchKeyword.isNotEmpty
? '검색 결과가 없습니다'
_appliedSearchKeyword.isNotEmpty || _selectedTransactionType != 'all'
? '검색 조건에 맞는 이력이 없습니다'
: '등록된 재고 이력이 없습니다',
style: ShadTheme.of(context).textTheme.large.copyWith(
color: ShadTheme.of(context).colorScheme.mutedForeground,
style: ShadcnTheme.bodyLarge.copyWith(
color: ShadcnTheme.mutedForeground,
),
),
const SizedBox(height: 8),
if (_appliedSearchKeyword.isNotEmpty || _selectedTransactionType != 'all')
ShadcnButton(
text: '필터 초기화',
onPressed: _clearSearch,
variant: ShadcnButtonVariant.secondary,
),
],
),
);
@@ -443,17 +468,23 @@ class _InventoryHistoryScreenState extends State<InventoryHistoryScreen> {
return Container(
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(color: Colors.black),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: ShadcnTheme.border),
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 고정 헤더
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: ShadTheme.of(context).colorScheme.muted.withValues(alpha: 0.3),
border: const Border(bottom: BorderSide(color: Colors.black)),
color: ShadcnTheme.muted.withValues(alpha: 0.3),
border: const Border(
bottom: BorderSide(color: Colors.black12),
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(8),
topRight: Radius.circular(8),
),
),
child: Row(children: _buildHeaderCells()),
),
@@ -472,39 +503,40 @@ class _InventoryHistoryScreenState extends State<InventoryHistoryScreen> {
@override
Widget build(BuildContext context) {
return Consumer<EquipmentHistoryController>(
builder: (context, controller, child) {
return BaseListScreen(
isLoading: controller.isLoading && controller.historyList.isEmpty,
error: controller.error,
onRefresh: () => controller.loadHistory(),
emptyMessage: _appliedSearchKeyword.isNotEmpty
? '검색 결과가 없습니다'
: '등록된 재고 이력이 없습니다',
emptyIcon: Icons.inventory_2_outlined,
return ChangeNotifierProvider<InventoryHistoryController>.value(
value: _controller,
child: Consumer<InventoryHistoryController>(
builder: (context, controller, child) {
return BaseListScreen(
isLoading: controller.isLoading && controller.historyItems.isEmpty,
error: controller.error,
onRefresh: () => controller.refresh(),
emptyMessage: _appliedSearchKeyword.isNotEmpty || _selectedTransactionType != 'all'
? '검색 조건에 맞는 이력이 없습니다'
: '등록된 재고 이력이 없습니다',
emptyIcon: Icons.inventory_2_outlined,
// 검색바
searchBar: _buildSearchBar(),
// 검색바
searchBar: _buildSearchBar(),
// 액션바
actionBar: _buildActionBar(),
// 액션바
actionBar: _buildActionBar(),
// 데이터 테이블
dataTable: _buildDataTable(controller.historyList),
// 데이터 테이블
dataTable: _buildDataTable(controller.historyItems),
// 페이지네이션
pagination: controller.totalPages > 1
? Pagination(
totalCount: controller.totalCount,
currentPage: controller.currentPage,
pageSize: AppConstants.historyPageSize, // controller.pageSize 대신 고정값 사용
onPageChanged: (page) => {
// 페이지 변경 로직 - 추후 Controller에 추가 예정
},
)
: null,
);
},
// 페이지네이션
pagination: controller.totalPages > 1
? Pagination(
totalCount: controller.totalCount,
currentPage: controller.currentPage,
pageSize: controller.pageSize,
onPageChanged: (page) => controller.goToPage(page),
)
: null,
);
},
),
);
}
}