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

@@ -195,8 +195,12 @@ class _AppLayoutState extends State<AppLayout>
case Routes.user:
return const UserList();
// License 시스템이 Maintenance로 대체됨
case Routes.maintenance:
case Routes.maintenanceSchedule:
case Routes.maintenance: // 메인 진입점을 알림 대시보드로 변경
return ChangeNotifierProvider(
create: (_) => GetIt.instance<MaintenanceController>(),
child: const MaintenanceAlertDashboard(),
);
case Routes.maintenanceSchedule: // 일정관리는 별도 라우트 유지
return ChangeNotifierProvider(
create: (_) => GetIt.instance<MaintenanceController>(),
child: const MaintenanceScheduleScreen(),
@@ -1116,16 +1120,16 @@ class SidebarMenu extends StatelessWidget {
badge: null,
hasSubMenu: true,
subMenuItems: collapsed ? [] : [
_buildSubMenuItem(
title: '일정 관리',
route: Routes.maintenanceSchedule,
isActive: currentRoute == Routes.maintenanceSchedule,
),
_buildSubMenuItem(
title: '알림 대시보드',
route: Routes.maintenanceAlert,
isActive: currentRoute == Routes.maintenanceAlert,
),
_buildSubMenuItem(
title: '일정 관리',
route: Routes.maintenanceSchedule,
isActive: currentRoute == Routes.maintenanceSchedule,
),
_buildSubMenuItem(
title: '이력 조회',
route: Routes.maintenanceHistory,

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
/// 공통 비고 입력 위젯
/// 여러 화면에서 재사용할 수 있도록 설계
@@ -24,17 +25,25 @@ class RemarkInput extends StatelessWidget {
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
minLines: minLines,
maxLines: maxLines,
enabled: enabled,
validator: validator,
decoration: InputDecoration(
labelText: label,
hintText: hint,
border: const OutlineInputBorder(),
),
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (label.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
label,
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
ShadInput(
controller: controller,
placeholder: Text(hint),
minLines: minLines,
maxLines: maxLines ?? minLines + 2,
enabled: enabled,
),
],
);
}
}

View File

@@ -6,9 +6,8 @@ import 'package:superport/services/equipment_service.dart';
import 'package:superport/core/errors/failures.dart';
import 'package:superport/core/utils/debug_logger.dart';
import 'package:superport/core/services/lookups_service.dart';
import 'package:superport/screens/equipment/controllers/equipment_history_controller.dart';
import 'package:superport/data/models/equipment/equipment_dto.dart';
import 'package:superport/data/models/equipment_history_dto.dart';
import 'package:superport/data/repositories/equipment_history_repository.dart';
/// 장비 입고 폼 컨트롤러
///
@@ -19,6 +18,7 @@ class EquipmentInFormController extends ChangeNotifier {
// final WarehouseService _warehouseService = GetIt.instance<WarehouseService>(); // 사용되지 않음 - 제거
// final CompanyService _companyService = GetIt.instance<CompanyService>(); // 사용되지 않음 - 제거
final LookupsService _lookupsService = GetIt.instance<LookupsService>();
final EquipmentHistoryRepository _equipmentHistoryRepository = GetIt.instance<EquipmentHistoryRepository>();
final int? equipmentInId; // 실제로는 장비 ID (입고 ID가 아님)
int? actualEquipmentId; // API 호출용 실제 장비 ID
EquipmentDto? preloadedEquipment; // 사전 로드된 장비 데이터
@@ -223,18 +223,39 @@ class EquipmentInFormController extends ChangeNotifier {
required Map<String, dynamic> preloadedData,
}) : equipmentInId = preloadedData['equipmentId'] as int?,
actualEquipmentId = preloadedData['equipmentId'] as int? {
print('DEBUG [withPreloadedData] preloadedData keys: ${preloadedData.keys.toList()}');
print('DEBUG [withPreloadedData] equipmentId from args: ${preloadedData['equipmentId']}');
print('DEBUG [withPreloadedData] equipmentInId after assignment: $equipmentInId');
print('DEBUG [withPreloadedData] actualEquipmentId: $actualEquipmentId');
isEditMode = equipmentInId != null;
print('DEBUG [withPreloadedData] isEditMode: $isEditMode');
// 전달받은 데이터로 즉시 초기화
preloadedEquipment = preloadedData['equipment'] as EquipmentDto?;
print('DEBUG [withPreloadedData] equipment 데이터 타입: ${preloadedData['equipment'].runtimeType}');
print('DEBUG [withPreloadedData] equipment 원시 데이터: ${preloadedData['equipment']}');
try {
preloadedEquipment = preloadedData['equipment'] as EquipmentDto?;
print('DEBUG [withPreloadedData] EquipmentDto 캐스팅 성공');
print('DEBUG [withPreloadedData] preloadedEquipment: ${preloadedEquipment != null ? "있음 (id: ${preloadedEquipment!.id})" : "null"}');
} catch (e, stackTrace) {
print('DEBUG [withPreloadedData] EquipmentDto 캐스팅 실패: $e');
print('DEBUG [withPreloadedData] StackTrace: $stackTrace');
preloadedEquipment = null;
}
final dropdownData = preloadedData['dropdownData'] as Map<String, dynamic>?;
print('DEBUG [withPreloadedData] dropdownData: ${dropdownData != null ? "있음 (${dropdownData.keys.length}개 키)" : "null"}');
if (dropdownData != null) {
_processDropdownData(dropdownData);
}
if (preloadedEquipment != null) {
print('DEBUG [withPreloadedData] _loadFromEquipment() 호출 예정');
_loadFromEquipment(preloadedEquipment!);
} else {
print('DEBUG [withPreloadedData] preloadedEquipment가 null이어서 _loadFromEquipment() 호출 안함');
}
_updateCanSave();
@@ -242,13 +263,19 @@ class EquipmentInFormController extends ChangeNotifier {
// 수정 모드 초기화 (외부에서 호출)
Future<void> initializeForEdit() async {
if (!isEditMode || equipmentInId == null) return;
print('DEBUG [initializeForEdit] 호출됨 - isEditMode: $isEditMode, equipmentInId: $equipmentInId');
if (!isEditMode || equipmentInId == null) {
print('DEBUG [initializeForEdit] 조건 미충족으로 return');
return;
}
print('DEBUG [initializeForEdit] 드롭다운 데이터와 장비 데이터 병렬 로드 시작');
// 드롭다운 데이터와 장비 데이터를 병렬로 로드
await Future.wait([
_waitForDropdownData(),
_loadEquipmentIn(),
]);
print('DEBUG [initializeForEdit] 병렬 로드 완료');
}
// 드롭다운 데이터 로드 대기
@@ -366,12 +393,24 @@ class EquipmentInFormController extends ChangeNotifier {
// 전달받은 장비 데이터로 폼 초기화 (간소화: 백엔드 JOIN 데이터 직접 활용)
void _loadFromEquipment(EquipmentDto equipment) {
print('DEBUG [_loadFromEquipment] 호출됨 - equipment.id: ${equipment.id}');
print('DEBUG [_loadFromEquipment] equipment.warehousesId: ${equipment.warehousesId}');
serialNumber = equipment.serialNumber;
barcode = equipment.barcode ?? '';
modelsId = equipment.modelsId;
purchasePrice = equipment.purchasePrice > 0 ? equipment.purchasePrice.toDouble() : null;
initialStock = 1;
selectedCompanyId = equipment.companiesId;
selectedWarehouseId = equipment.warehousesId; // ✅ 기존 창고 ID 복원 (항상 null)
print('DEBUG [_loadFromEquipment] selectedWarehouseId after assignment: $selectedWarehouseId');
// 🔧 창고 정보가 null이므로 Equipment History에서 비동기로 로드 필요
if (selectedWarehouseId == null) {
print('DEBUG [_loadFromEquipment] 창고 정보 null - 비동기 로드 예약');
// 비동기 메서드는 동기 메서드에서 직접 호출 불가 -> Future 예약
Future.microtask(() => _loadWarehouseFromHistory(equipment.id));
}
// ✅ 간소화: 백엔드 JOIN 데이터 직접 사용 (복잡한 Controller 조회 제거)
manufacturer = equipment.vendorName ?? '제조사 정보 없음';
@@ -386,8 +425,10 @@ class EquipmentInFormController extends ChangeNotifier {
remarkController.text = equipment.remark ?? '';
warrantyNumberController.text = equipment.warrantyNumber;
// 수정 모드에서 입고지 기본값 설정
// 수정 모드에서는 기존 창고 ID를 우선 사용, null인 경우에만 기본값 설정
// (이제 위에서 selectedWarehouseId = equipment.warehousesId 로 설정하므로 이 조건은 거의 실행되지 않음)
if (isEditMode && selectedWarehouseId == null && warehouses.isNotEmpty) {
// 기존 창고 ID가 없는 경우에만 첫 번째 창고 선택
selectedWarehouseId = warehouses.keys.first;
}
@@ -397,10 +438,51 @@ class EquipmentInFormController extends ChangeNotifier {
_updateCanSave();
notifyListeners(); // UI 즉시 업데이트
}
/// Equipment History에서 창고 정보를 로드 (비동기)
Future<void> _loadWarehouseFromHistory(int equipmentId) async {
try {
print('DEBUG [_loadWarehouseFromHistory] 시작 - 장비 ID: $equipmentId');
final histories = await _equipmentHistoryRepository.getEquipmentHistoriesByEquipmentId(equipmentId);
print('DEBUG [_loadWarehouseFromHistory] API 응답: ${histories.length}개 기록');
if (histories.isNotEmpty) {
// 가장 최근 이력의 창고 ID 사용
final latestHistory = histories.first;
selectedWarehouseId = latestHistory.warehousesId;
final warehouseName = warehouses[selectedWarehouseId] ?? '알 수 없는 창고';
print('DEBUG [_loadWarehouseFromHistory] 창고 정보 찾음: $warehouseName (ID: $selectedWarehouseId)');
print('DEBUG [_loadWarehouseFromHistory] 최근 거래: ${latestHistory.transactionType} (${latestHistory.transactedAt})');
notifyListeners(); // UI 업데이트
} else {
print('DEBUG [_loadWarehouseFromHistory] 이력 없음 - 기본 창고 사용');
if (warehouses.isNotEmpty) {
selectedWarehouseId = warehouses.keys.first;
print('DEBUG [_loadWarehouseFromHistory] 기본 창고로 설정: $selectedWarehouseId');
notifyListeners();
}
}
} catch (e) {
print('DEBUG [_loadWarehouseFromHistory] 오류: $e');
// 오류 발생시 기본 창고 사용
if (warehouses.isNotEmpty) {
selectedWarehouseId = warehouses.keys.first;
print('DEBUG [_loadWarehouseFromHistory] 오류로 인한 기본 창고 설정: $selectedWarehouseId');
notifyListeners();
}
}
}
// 기존 데이터 로드(수정 모드)
Future<void> _loadEquipmentIn() async {
if (equipmentInId == null) return;
print('DEBUG [_loadEquipmentIn] 호출됨 - equipmentInId: $equipmentInId');
if (equipmentInId == null) {
print('DEBUG [_loadEquipmentIn] equipmentInId가 null이어서 return');
return;
}
_isLoading = true;
_error = null;
@@ -436,6 +518,51 @@ class EquipmentInFormController extends ChangeNotifier {
_serialNumber = equipment.serialNumber;
_modelsId = equipment.modelsId; // 백엔드 실제 필드
selectedCompanyId = equipment.companiesId; // companyId → companiesId
selectedWarehouseId = equipment.warehousesId; // ✅ 기존 창고 ID 복원 (백엔드에서 null)
print('DEBUG [_loadEquipmentIn] equipment.warehousesId: ${equipment.warehousesId}');
print('DEBUG [_loadEquipmentIn] selectedWarehouseId after assignment: $selectedWarehouseId');
// 🔧 창고 정보 우회 처리: Equipment History에서 가장 최근 창고 정보 조회
// 백엔드 Equipment API가 창고 정보를 제공하지 않으므로 항상 Equipment History에서 조회
try {
print('DEBUG [_loadEquipmentIn] Equipment History API 호출 시작');
final equipmentHistories = await _equipmentHistoryRepository.getEquipmentHistoriesByEquipmentId(equipment.id);
print('DEBUG [_loadEquipmentIn] Equipment History API 응답: ${equipmentHistories.length}개 기록');
if (equipmentHistories.isNotEmpty) {
// 가장 최근 이력의 창고 ID 사용 (이미 날짜 순으로 정렬됨)
final latestHistory = equipmentHistories.first;
selectedWarehouseId = latestHistory.warehousesId;
// 창고 이름 찾기
final warehouseName = warehouses[selectedWarehouseId] ?? '알 수 없는 창고';
print('DEBUG [_loadEquipmentIn] Equipment History에서 창고 정보 찾음: $warehouseName (ID: $selectedWarehouseId)');
print('DEBUG [_loadEquipmentIn] 최근 거래: ${latestHistory.transactionType} (${latestHistory.transactedAt})');
DebugLogger.log('창고 정보 우회 조회 성공', tag: 'EQUIPMENT_IN', data: {
'equipmentId': equipment.id,
'warehouseId': selectedWarehouseId,
'warehouseName': warehouseName,
'lastTransaction': latestHistory.transactionType,
'transactedAt': latestHistory.transactedAt.toIso8601String(),
});
} else {
print('DEBUG [_loadEquipmentIn] Equipment History가 비어있음 - 기본 창고 사용');
// 창고 정보를 찾을 수 없으면 기본값으로 첫 번째 창고 사용
if (warehouses.isNotEmpty) {
selectedWarehouseId = warehouses.keys.first;
print('DEBUG [_loadEquipmentIn] 기본 창고로 설정: $selectedWarehouseId');
}
}
} catch (e) {
print('DEBUG [_loadEquipmentIn] Equipment History에서 창고 정보 찾기 실패: $e');
// 창고 정보를 찾을 수 없으면 기본값으로 첫 번째 창고 사용
if (warehouses.isNotEmpty) {
selectedWarehouseId = warehouses.keys.first;
print('DEBUG [_loadEquipmentIn] 기본 창고로 설정: $selectedWarehouseId');
}
}
purchasePrice = equipment.purchasePrice > 0 ? equipment.purchasePrice.toDouble() : null; // int → double 변환, 0이면 null
remarkController.text = equipment.remark ?? '';
@@ -520,6 +647,13 @@ class EquipmentInFormController extends ChangeNotifier {
}
formKey.currentState!.save();
// 입고지 필수 선택 검증 (신규 생성 모드에서만)
if (!isEditMode && selectedWarehouseId == null) {
_error = '입고지는 필수 선택 항목입니다. 입고지를 선택해주세요.';
if (!_disposed) notifyListeners();
return false;
}
_isSaving = true;
_error = null;
_updateCanSave(); // 저장 시작 시 canSave 상태 업데이트
@@ -641,34 +775,50 @@ class EquipmentInFormController extends ChangeNotifier {
'equipmentId': createdEquipment.id,
});
// 2. Equipment History (입고 기록) 생성
// 2. Equipment History (입고 기록) 생성 - 출고 시스템과 동일한 패턴 적용
print('🔍 [입고 처리] selectedWarehouseId: $selectedWarehouseId, createdEquipment.id: ${createdEquipment.id}');
if (selectedWarehouseId != null && createdEquipment.id != null) {
// 입고지 정보 상세 로깅
final warehouseName = warehouses[selectedWarehouseId] ?? '알 수 없는 창고';
print('🏪 [입고 처리] 입고지 정보:');
print(' - 창고 ID: $selectedWarehouseId');
print(' - 창고 이름: $warehouseName');
print(' - 장비 ID: ${createdEquipment.id}');
print(' - 입고 수량: $_initialStock');
try {
// EquipmentHistoryController를 통한 입고 처리
final historyController = EquipmentHistoryController();
// 입고 처리 (EquipmentHistoryRequestDto 객체 생성)
final historyRequest = EquipmentHistoryRequestDto(
equipmentsId: createdEquipment.id, // null 체크 이미 완료되어 ! 연산자 불필요
// ✅ Repository 직접 호출 (출고 시스템과 동일한 패턴)
await _equipmentHistoryRepository.createStockIn(
equipmentsId: createdEquipment.id,
warehousesId: selectedWarehouseId!,
transactionType: 'I', // 입고: 'I'
quantity: _initialStock,
transactedAt: DateTime.now(),
transactedAt: DateTime.now().toUtc().copyWith(microsecond: 0),
remark: '장비 등록 시 자동 입고',
);
await historyController.createHistory(historyRequest);
print('✅ [입고 처리] Equipment History 생성 성공');
DebugLogger.log('Equipment History 생성 성공', tag: 'EQUIPMENT_IN', data: {
'equipmentId': createdEquipment.id,
'warehouseId': selectedWarehouseId,
'warehouseName': warehouseName,
'quantity': _initialStock,
});
} catch (e) {
// 입고 실패 시에도 장비는 이미 생성되었으므로 경고만 표시
// 입고 이력 생성 실패시 전체 프로세스 실패 처리 (출고 시스템과 동일)
print('❌ [입고 처리] Equipment History 생성 실패: $e');
DebugLogger.logError('Equipment History 생성 실패', error: e);
_error = '장비는 등록되었으나 입고 처리 중 오류가 발생했습니다.';
throw Exception('입고 이력 생성에 실패했습니다. 다시 시도해주세요: $e');
}
} else {
// 필수 정보 누락 시 에러
final missingInfo = <String>[];
if (selectedWarehouseId == null) missingInfo.add('입고지');
if (createdEquipment.id == null) missingInfo.add('장비 ID');
final errorMsg = '입고 처리 실패: ${missingInfo.join(', ')} 정보가 누락되었습니다';
print('❌ [입고 처리] $errorMsg');
_error = errorMsg;
}
DebugLogger.log('입고 처리 완료', tag: 'EQUIPMENT_IN');

View File

@@ -11,12 +11,14 @@ import 'package:superport/data/models/lookups/lookup_data.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/data/models/equipment/equipment_dto.dart';
import 'package:superport/domain/usecases/equipment/search_equipment_usecase.dart';
import 'package:superport/services/equipment_history_service.dart';
/// 장비 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러 (리팩토링 버전)
/// BaseListController를 상속받아 공통 기능을 재사용
class EquipmentListController extends BaseListController<UnifiedEquipment> {
late final EquipmentService _equipmentService;
late final LookupsService _lookupsService;
late final EquipmentHistoryService _historyService;
// 추가 상태 관리
final Set<String> selectedEquipmentIds = {}; // 'id:status' 형식
@@ -62,6 +64,7 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
throw Exception('LookupsService not registered in GetIt');
}
_historyService = EquipmentHistoryService();
}
@override
@@ -101,9 +104,9 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
// DTO를 UnifiedEquipment로 변환
print('DEBUG [EquipmentListController] Converting ${apiEquipmentDtos.items.length} DTOs to UnifiedEquipment');
final items = apiEquipmentDtos.items.map((dto) {
final items = await Future.wait(apiEquipmentDtos.items.map((dto) async {
// 🔧 [DEBUG] JOIN된 데이터 로깅
print('DEBUG [EquipmentListController] DTO ID: ${dto.id}, companyName: "${dto.companyName}"');
print('DEBUG [EquipmentListController] DTO ID: ${dto.id}, companyName: "${dto.companyName}", warehousesName: "${dto.warehousesName}", warehousesId: ${dto.warehousesId}');
final equipment = Equipment(
id: dto.id,
modelsId: dto.modelsId, // Sprint 3: Model FK 사용
@@ -125,18 +128,34 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
// 간단한 Company 정보 생성 (사용하지 않으므로 제거)
// final company = dto.companyName != null ? ... : null;
// 각 장비의 최신 히스토리를 조회해서 실제 상태 가져오기
String status = 'I'; // 기본값: 입고 (I)
DateTime transactionDate = dto.registeredAt ?? DateTime.now();
try {
final histories = await _historyService.getEquipmentHistoriesByEquipmentId(dto.id);
if (histories.isNotEmpty) {
// 최신 히스토리의 transaction_type 사용
// 히스토리는 최신순으로 정렬되어 있다고 가정
status = histories.first.transactionType ?? 'I';
transactionDate = histories.first.transactedAt ?? transactionDate;
print('DEBUG [EquipmentListController] Equipment ${dto.id} status from history: $status');
}
} catch (e) {
print('DEBUG [EquipmentListController] Failed to get history for equipment ${dto.id}: $e');
// 히스토리 조회 실패시 기본값 사용
}
final unifiedEquipment = UnifiedEquipment(
id: dto.id,
equipment: equipment,
date: dto.registeredAt ?? DateTime.now(), // EquipmentDto에는 createdAt 대신 registeredAt 존재
status: '입고', // EquipmentDto에 status 필드 없음 - 기본값 설정 (실제는 Equipment_History에서 상태 관리)
date: transactionDate, // 최신 거래 날짜 사용
status: status, // 실제 equipment_history의 transaction_type 사용
notes: dto.remark, // EquipmentDto에 remark 필드 존재
// 🔧 [BUG FIX] 누락된 위치 정보 필드들 추가
// 문제: 장비 리스트에서 위치 정보(현재 위치, 창고 위치)가 표시되지 않음
// 원인: EquipmentDto에 warehouseName 필드가 없음 (백엔드 스키마에 warehouse 정보 분리)
// 해결: 현재는 companyName만 사용, warehouseLocation은 null로 설정
// 백엔드에서 warehouses_name 제공하므로 이를 사용
currentCompany: dto.companyName, // API company_name → currentCompany
warehouseLocation: null, // EquipmentDto에 warehouse_name 필드 없음
warehouseLocation: dto.warehousesName, // API warehouses_name → warehouseLocation
// currentBranch는 EquipmentListDto에 없으므로 null (백엔드 API 구조 변경으로 지점 개념 제거)
currentBranch: null,
// ⚡ [FIX] 백엔드 직접 제공 필드들 추가 - 화면에서 N/A 문제 해결
@@ -144,10 +163,10 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
vendorName: dto.vendorName, // API vendor_name → UI 제조사 컬럼
modelName: dto.modelName, // API model_name → UI 모델명 컬럼
);
// 🔧 [DEBUG] 변환된 UnifiedEquipment 로깅 (필요 시 활성화)
// print('DEBUG [EquipmentListController] UnifiedEquipment ID: ${unifiedEquipment.id}, currentCompany: "${unifiedEquipment.currentCompany}", warehouseLocation: "${unifiedEquipment.warehouseLocation}"');
// 🔧 [DEBUG] 변환된 UnifiedEquipment 로깅
print('DEBUG [EquipmentListController] UnifiedEquipment ID: ${unifiedEquipment.id}, currentCompany: "${unifiedEquipment.currentCompany}", warehouseLocation: "${unifiedEquipment.warehouseLocation}"');
return unifiedEquipment;
}).toList();
}));
// API에서 반환한 실제 메타데이터 사용
final meta = PaginationMeta(
@@ -406,7 +425,7 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
/// 선택된 장비들을 폐기 처리
Future<void> disposeSelectedEquipments({String? reason}) async {
final selectedEquipments = getSelectedEquipments()
.where((equipment) => equipment.status != EquipmentStatus.disposed)
.where((equipment) => equipment.status != 'P') // 영문 코드로 통일
.toList();
if (selectedEquipments.isEmpty) {
@@ -484,7 +503,7 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
/// 선택된 입고 상태 장비 개수
int getSelectedInStockCount() {
return selectedEquipmentIds
.where((key) => key.endsWith(':입고'))
.where((key) => key.endsWith(':I')) // 영문 코드만 체크
.length;
}
@@ -520,6 +539,7 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
/// 특정 상태의 선택된 장비 개수
int getSelectedEquipmentCountByStatus(String status) {
// status가 이미 코드(I, O, T 등)일 수도 있고, 상수명(EquipmentStatus.in_ 등)일 수도 있음
return selectedEquipmentIds
.where((key) => key.endsWith(':$status'))
.length;

View File

@@ -0,0 +1,314 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:superport/data/models/equipment/equipment_dto.dart';
import 'package:superport/data/models/company/company_dto.dart';
import 'package:superport/data/models/stock_status_dto.dart';
import 'package:superport/data/repositories/equipment_history_repository.dart';
import 'package:superport/domain/repositories/company_repository.dart';
import 'package:superport/data/repositories/company_repository_impl.dart';
import 'package:superport/data/datasources/remote/company_remote_datasource.dart';
import 'package:superport/data/datasources/remote/api_client.dart';
import 'package:superport/services/equipment_warehouse_cache_service.dart';
class EquipmentOutboundController extends ChangeNotifier {
final List<EquipmentDto> selectedEquipments;
late final CompanyRepository _companyRepository;
late final EquipmentHistoryRepository _equipmentHistoryRepository;
late final EquipmentWarehouseCacheService _warehouseCacheService;
// Form controllers
final TextEditingController remarkController = TextEditingController();
// State variables
bool _isLoading = false;
bool _isLoadingCompanies = false;
bool _isLoadingWarehouseInfo = false;
String? _errorMessage;
String? _companyError;
String? _warehouseError;
// Form data
DateTime _transactionDate = DateTime.now();
List<CompanyDto> _companies = [];
CompanyDto? _selectedCompany;
final Map<int, DateTime> _warrantyDates = {}; // 각 장비의 워런티 날짜 관리
// Getters
bool get isLoading => _isLoading;
bool get isLoadingCompanies => _isLoadingCompanies;
bool get isLoadingWarehouseInfo => _isLoadingWarehouseInfo;
String? get errorMessage => _errorMessage;
String? get companyError => _companyError;
String? get warehouseError => _warehouseError;
DateTime get transactionDate => _transactionDate;
List<CompanyDto> get companies => _companies;
CompanyDto? get selectedCompany => _selectedCompany;
bool get canSubmit =>
!_isLoading &&
_selectedCompany != null &&
selectedEquipments.isNotEmpty;
EquipmentOutboundController({
required this.selectedEquipments,
}) {
// Initialize repositories directly with proper dependencies
final apiClient = ApiClient();
final companyRemoteDataSource = CompanyRemoteDataSourceImpl(apiClient);
_companyRepository = CompanyRepositoryImpl(remoteDataSource: companyRemoteDataSource);
// Initialize EquipmentHistoryRepository with ApiClient's Dio instance
// ApiClient has proper auth headers and base URL configuration
final dio = apiClient.dio; // Use the authenticated Dio instance from ApiClient
_equipmentHistoryRepository = EquipmentHistoryRepositoryImpl(dio);
// Initialize warehouse cache service
_warehouseCacheService = EquipmentWarehouseCacheService();
// 각 장비의 현재 워런티 날짜로 초기화
for (final equipment in selectedEquipments) {
final id = equipment.id;
final warrantyDate = equipment.warrantyEndedAt;
if (id != null && warrantyDate != null) {
_warrantyDates[id] = warrantyDate;
}
}
}
set transactionDate(DateTime value) {
_transactionDate = value;
notifyListeners();
}
set selectedCompany(CompanyDto? value) {
_selectedCompany = value;
notifyListeners();
}
Future<void> initialize() async {
// 병렬로 회사 정보와 창고 캐시 로드
await Future.wait([
loadCompanies(),
_loadWarehouseCache(),
]);
}
Future<void> loadCompanies() async {
print('[EquipmentOutboundController] loadCompanies called');
_isLoadingCompanies = true;
_companyError = null;
notifyListeners();
try {
print('[EquipmentOutboundController] Calling _companyRepository.getCompanies');
final result = await _companyRepository.getCompanies(
limit: 1000, // 모든 회사를 가져오기 위해 큰 값 설정
);
result.fold(
(failure) {
print('[EquipmentOutboundController] Company loading failed: ${failure.message}');
_companyError = failure.message;
},
(data) {
print('[EquipmentOutboundController] Companies loaded successfully: ${data.items.length} companies');
// Convert Company to CompanyDto - only use required fields
_companies = data.items
.map((company) => CompanyDto(
id: company.id,
name: company.name,
contactName: '', // Default value for required field
contactPhone: '', // Default value for required field
contactEmail: '', // Default value for required field
address: company.address.toString(),
isCustomer: company.isCustomer,
))
.where((c) => c.isCustomer == true)
.toList();
print('[EquipmentOutboundController] Filtered customer companies: ${_companies.length}');
},
);
} catch (e, stackTrace) {
print('[EquipmentOutboundController] Exception in loadCompanies: $e');
print('[EquipmentOutboundController] Stack trace: $stackTrace');
_companyError = '회사 목록을 불러오는 중 오류가 발생했습니다: $e';
} finally {
_isLoadingCompanies = false;
notifyListeners();
print('[EquipmentOutboundController] loadCompanies completed');
}
}
/// 창고 캐시 로딩
Future<void> _loadWarehouseCache() async {
if (_warehouseCacheService.needsRefresh()) {
_isLoadingWarehouseInfo = true;
_warehouseError = null;
notifyListeners();
try {
final success = await _warehouseCacheService.loadCache();
if (!success) {
_warehouseError = _warehouseCacheService.lastError ?? '창고 정보 로딩 실패';
}
} catch (e) {
_warehouseError = '창고 정보 로딩 중 오류: $e';
} finally {
_isLoadingWarehouseInfo = false;
notifyListeners();
}
}
}
/// 장비의 현재 창고 정보 조회 (Stock Status 기반)
///
/// [equipment]: 조회할 장비
///
/// Returns: 창고명 (Stock Status 우선, Fallback으로 Equipment DTO 사용)
String getEquipmentCurrentWarehouse(EquipmentDto equipment) {
// 디버깅: 실제 Equipment DTO 데이터 출력
print('[EquipmentOutboundController] Equipment ${equipment.id} 창고 정보:');
print(' - warehousesId: ${equipment.warehousesId}');
print(' - warehousesName: ${equipment.warehousesName}');
print(' - serialNumber: ${equipment.serialNumber}');
if (_warehouseError != null) {
print('[EquipmentOutboundController] Stock Status API 실패, Equipment DTO 사용');
final fallbackName = equipment.warehousesName ?? '창고 미지정';
print(' - Fallback 결과: $fallbackName');
return fallbackName;
}
// Primary: Stock Status API 기반 정보 사용
final stockInfo = _warehouseCacheService.getEquipmentStock(equipment.id);
print('[EquipmentOutboundController] Stock Status API 결과:');
print(' - stockInfo 존재: ${stockInfo != null}');
if (stockInfo != null) {
print(' - stockInfo.warehouseName: ${stockInfo.warehouseName}');
print(' - stockInfo.warehouseId: ${stockInfo.warehouseId}');
}
final finalResult = stockInfo?.warehouseName ??
equipment.warehousesName ??
'입출고 이력 없음';
print(' - 최종 결과: $finalResult');
return finalResult;
}
/// 장비의 현재 창고 ID 조회
int? getEquipmentCurrentWarehouseId(EquipmentDto equipment) {
// Primary: Stock Status API 기반 정보 사용
final stockInfo = _warehouseCacheService.getEquipmentStock(equipment.id);
return stockInfo?.warehouseId ?? equipment.warehousesId;
}
/// 장비의 재고 현황 정보 조회
StockStatusDto? getEquipmentStockStatus(EquipmentDto equipment) {
return _warehouseCacheService.getEquipmentStock(equipment.id);
}
/// 출고 후 창고 캐시 갱신
Future<void> _refreshWarehouseCache() async {
print('[EquipmentOutboundController] 출고 완료 후 창고 캐시 갱신 시작...');
try {
await _warehouseCacheService.refreshCache();
print('[EquipmentOutboundController] 창고 캐시 갱신 완료');
} catch (e) {
print('[EquipmentOutboundController] 창고 캐시 갱신 실패: $e');
// 갱신 실패해도 출고 프로세스는 성공으로 간주
}
}
Future<bool> processOutbound() async {
print('[EquipmentOutboundController] processOutbound called');
print('[EquipmentOutboundController] canSubmit: $canSubmit');
print('[EquipmentOutboundController] selectedEquipments count: ${selectedEquipments.length}');
print('[EquipmentOutboundController] selectedCompany: ${_selectedCompany?.name} (ID: ${_selectedCompany?.id})');
print('[EquipmentOutboundController] API Base URL: ${_equipmentHistoryRepository.toString()}');
if (!canSubmit) {
print('[EquipmentOutboundController] Cannot submit - validation failed');
return false;
}
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
print('[EquipmentOutboundController] Starting outbound process for ${selectedEquipments.length} equipments');
// Process each selected equipment
for (int i = 0; i < selectedEquipments.length; i++) {
final equipment = selectedEquipments[i];
// 개선된 창고 정보 조회 (Stock Status API 우선)
final currentWarehouseName = getEquipmentCurrentWarehouse(equipment);
final currentWarehouseId = getEquipmentCurrentWarehouseId(equipment);
print('[EquipmentOutboundController] Processing equipment ${i+1}/${selectedEquipments.length}');
print('[EquipmentOutboundController] Equipment ID: ${equipment.id}');
print('[EquipmentOutboundController] Equipment Serial: ${equipment.serialNumber}');
print('[EquipmentOutboundController] Current Warehouse (Stock Status): $currentWarehouseName (ID: $currentWarehouseId)');
print('[EquipmentOutboundController] Original Warehouse (DTO): ${equipment.warehousesName} (ID: ${equipment.warehousesId})');
await _equipmentHistoryRepository.createStockOut(
equipmentsId: equipment.id,
warehousesId: currentWarehouseId ?? equipment.warehousesId, // 개선된 창고 정보 사용
companyIds: _selectedCompany?.id != null ? [_selectedCompany!.id!] : null,
quantity: 1,
transactedAt: _transactionDate,
remark: remarkController.text.isNotEmpty ? remarkController.text : null,
);
print('[EquipmentOutboundController] Successfully processed equipment ${equipment.id}');
}
print('[EquipmentOutboundController] All equipments processed successfully');
// 출고 완료 후 창고 캐시 갱신 (백그라운드에서 실행)
unawaited(_refreshWarehouseCache());
return true;
} catch (e, stackTrace) {
print('[EquipmentOutboundController] ERROR during outbound process: $e');
print('[EquipmentOutboundController] Stack trace: $stackTrace');
_errorMessage = '출고 처리 중 오류가 발생했습니다: $e';
notifyListeners();
return false;
} finally {
_isLoading = false;
notifyListeners();
print('[EquipmentOutboundController] processOutbound completed');
}
}
String formatDate(DateTime date) {
return DateFormat('yyyy-MM-dd').format(date);
}
String formatPrice(int? price) {
if (price == null) return '-';
final formatter = NumberFormat('#,###');
return '${formatter.format(price)}';
}
DateTime? getWarrantyDate(int equipmentId) {
return _warrantyDates[equipmentId];
}
void updateWarrantyDate(int equipmentId, DateTime date) {
_warrantyDates[equipmentId] = date;
notifyListeners();
}
@override
void dispose() {
remarkController.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,535 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport/data/models/equipment/equipment_dto.dart';
import 'package:superport/screens/equipment/controllers/equipment_outbound_controller.dart';
import 'package:superport/screens/common/widgets/standard_dropdown.dart';
import 'package:superport/screens/common/widgets/remark_input.dart';
class EquipmentOutboundDialog extends StatefulWidget {
final List<EquipmentDto> selectedEquipments;
const EquipmentOutboundDialog({
super.key,
required this.selectedEquipments,
});
@override
State<EquipmentOutboundDialog> createState() => _EquipmentOutboundDialogState();
}
class _EquipmentOutboundDialogState extends State<EquipmentOutboundDialog> {
late final EquipmentOutboundController _controller;
@override
void initState() {
super.initState();
_controller = EquipmentOutboundController(
selectedEquipments: widget.selectedEquipments,
);
_controller.initialize();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: _controller,
child: Consumer<EquipmentOutboundController>(
builder: (context, controller, child) {
return ShadDialog(
title: Text('장비 출고 (${widget.selectedEquipments.length}개)'),
actions: [
ShadButton.outline(
onPressed: () => Navigator.of(context).pop(),
child: const Text('취소'),
),
ShadButton(
onPressed: controller.canSubmit
? () async {
print('[EquipmentOutboundDialog] 출고 버튼 클릭됨');
final success = await controller.processOutbound();
if (context.mounted) {
if (success) {
print('[EquipmentOutboundDialog] 출고 처리 성공, 다이얼로그 닫기');
Navigator.of(context).pop(true); // true를 반환하여 부모에서 새로고침 할 수 있도록
ShadToaster.of(context).show(
const ShadToast(
title: Text('출고 완료'),
description: Text('장비 출고가 완료되었습니다.'),
),
);
} else {
print('[EquipmentOutboundDialog] 출고 처리 실패');
// 에러 메시지는 controller에서 이미 설정되므로 추가 토스트는 필요 없음
// 다이얼로그는 열린 상태로 유지하여 사용자가 에러 메시지를 볼 수 있도록 함
}
}
}
: null,
child: controller.isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('출고 처리'),
),
],
child: Material(
color: Colors.transparent,
child: Container(
width: 800,
height: 600,
padding: const EdgeInsets.all(24),
child: controller.isLoading
? const Center(child: ShadProgress())
: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 창고 정보 로딩 상태 표시
if (controller.isLoadingWarehouseInfo)
Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.shade200),
),
child: const Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 12),
Text('장비 창고 정보 로딩 중...'),
],
),
),
// 창고 정보 로딩 오류 표시
if (controller.warehouseError != null)
Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.shade200),
),
child: Row(
children: [
Icon(Icons.warning, color: Colors.orange.shade600, size: 20),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'창고 정보 로딩 실패',
style: TextStyle(fontWeight: FontWeight.w500),
),
Text(
'기존 장비 정보의 창고 데이터를 사용합니다.',
style: TextStyle(
fontSize: 12,
color: Colors.orange.shade700,
),
),
],
),
),
],
),
),
// 선택된 장비 목록
_buildEquipmentSummary(controller),
const SizedBox(height: 24),
// 출고 정보 입력
_buildOutboundForm(controller),
],
),
),
),
),
);
},
),
);
}
Widget _buildEquipmentSummary(EquipmentOutboundController controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'선택된 장비',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
// 장비별 상세 정보 카드
Container(
constraints: const BoxConstraints(maxHeight: 300), // 스크롤 가능한 영역
child: SingleChildScrollView(
child: Column(
children: widget.selectedEquipments.map((equipment) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
child: ShadCard(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 첫번째 줄: 제조사, 모델명
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('제조사', style: TextStyle(fontSize: 12, color: Colors.grey)),
Text(equipment.vendorName ?? '-', style: const TextStyle(fontWeight: FontWeight.w500)),
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('모델명', style: TextStyle(fontSize: 12, color: Colors.grey)),
Text(equipment.modelName ?? '-', style: const TextStyle(fontWeight: FontWeight.w500)),
],
),
),
],
),
const SizedBox(height: 12),
// 두번째 줄: 시리얼번호, 바코드
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('시리얼번호', style: TextStyle(fontSize: 12, color: Colors.grey)),
Text(equipment.serialNumber ?? '-', style: const TextStyle(fontWeight: FontWeight.w500)),
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('바코드', style: TextStyle(fontSize: 12, color: Colors.grey)),
Text(equipment.barcode ?? '-', style: const TextStyle(fontWeight: FontWeight.w500)),
],
),
),
],
),
const SizedBox(height: 12),
// 세번째 줄: 구매가격, 등록일
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('구매가격', style: TextStyle(fontSize: 12, color: Colors.grey)),
Text(controller.formatPrice(equipment.purchasePrice), style: const TextStyle(fontWeight: FontWeight.w500)),
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('등록일', style: TextStyle(fontSize: 12, color: Colors.grey)),
Text(
equipment.registeredAt != null
? controller.formatDate(equipment.registeredAt!)
: '-',
style: const TextStyle(fontWeight: FontWeight.w500)
),
],
),
),
],
),
const SizedBox(height: 12),
// 네번째 줄: 워런티 만료일 (수정 가능)
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('워런티 만료일', style: TextStyle(fontSize: 12, color: Colors.grey)),
Row(
children: [
Builder(
builder: (context) {
final equipmentId = equipment.id;
if (equipmentId != null) {
final warrantyDate = controller.getWarrantyDate(equipmentId);
if (warrantyDate != null) {
return Text(
controller.formatDate(warrantyDate),
style: const TextStyle(fontWeight: FontWeight.w500),
);
} else if (equipment.warrantyEndedAt != null) {
return Text(
controller.formatDate(equipment.warrantyEndedAt),
style: const TextStyle(fontWeight: FontWeight.w500),
);
}
}
return const Text('미지정', style: TextStyle(fontWeight: FontWeight.w500));
},
),
const SizedBox(width: 8),
InkWell(
onTap: () async {
final equipmentId = equipment.id;
if (equipmentId != null) {
final date = await showDatePicker(
context: context,
initialDate: controller.getWarrantyDate(equipmentId) ??
equipment.warrantyEndedAt ??
DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (date != null) {
controller.updateWarrantyDate(equipmentId, date);
}
}
},
child: const Icon(Icons.edit, size: 16, color: Colors.blue),
),
],
),
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('현재 창고', style: TextStyle(fontSize: 12, color: Colors.grey)),
Builder(
builder: (context) {
// 개선된 창고 정보 조회 (Stock Status API 기반)
final currentWarehouse = controller.getEquipmentCurrentWarehouse(equipment);
final stockStatus = controller.getEquipmentStockStatus(equipment);
final isFromStockApi = stockStatus != null;
return Row(
children: [
// 창고명 표시
Expanded(
child: Text(
currentWarehouse,
style: TextStyle(
fontWeight: FontWeight.w500,
color: currentWarehouse == '위치 미확인'
? Colors.red
: isFromStockApi
? Colors.green.shade700 // Stock API 기반 = 정확한 정보
: Colors.orange.shade700, // Equipment DTO 기반 = 참고 정보
),
),
),
// 데이터 소스 표시 아이콘
if (isFromStockApi)
Tooltip(
message: '실시간 재고 현황 기반',
child: Icon(
Icons.verified,
size: 16,
color: Colors.green.shade600,
),
)
else if (currentWarehouse != '위치 미확인')
Tooltip(
message: '장비 등록 정보 기반 (참고용)',
child: Icon(
Icons.info_outline,
size: 16,
color: Colors.orange.shade600,
),
),
],
);
},
),
// 재고 현황 추가 정보 (Stock Status API 사용 가능 시)
Builder(
builder: (context) {
final stockStatus = controller.getEquipmentStockStatus(equipment);
if (stockStatus != null && stockStatus.lastTransactionDate != null) {
return Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
'최종 이동: ${controller.formatDate(stockStatus.lastTransactionDate!)}',
style: TextStyle(
fontSize: 10,
color: Colors.grey.shade600,
),
),
);
}
return const SizedBox.shrink();
},
),
],
),
),
],
),
// 비고가 있으면 표시
if (equipment.remark != null && equipment.remark!.isNotEmpty) ...[
const SizedBox(height: 12),
const Divider(),
const SizedBox(height: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('장비 비고', style: TextStyle(fontSize: 12, color: Colors.grey)),
const SizedBox(height: 4),
Text(equipment.remark!, style: const TextStyle(fontWeight: FontWeight.w400)),
],
),
],
],
),
),
),
);
}).toList(),
),
),
),
],
);
}
Widget _buildOutboundForm(EquipmentOutboundController controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'출고 정보',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
// 거래일
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('거래일 *', style: TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
InkWell(
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: controller.transactionDate,
firstDate: DateTime(2000),
lastDate: DateTime.now(),
);
if (date != null) {
controller.transactionDate = date;
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(controller.formatDate(controller.transactionDate)),
const Icon(Icons.calendar_today, size: 18),
],
),
),
),
],
),
const SizedBox(height: 16),
// 출고 대상 회사
StandardIntDropdown<dynamic>(
label: '출고 대상 회사',
isRequired: true,
items: controller.companies,
isLoading: controller.isLoadingCompanies,
error: controller.companyError,
onRetry: () => controller.loadCompanies(),
selectedValue: controller.selectedCompany,
onChanged: (value) {
controller.selectedCompany = value;
},
itemBuilder: (item) => Text(item.name),
selectedItemBuilder: (item) => Text(item.name),
idExtractor: (item) => item.id ?? 0,
),
const SizedBox(height: 16),
// 비고
RemarkInput(
controller: controller.remarkController,
label: '비고',
hint: '출고 관련 비고사항을 입력하세요',
minLines: 3,
),
// 에러 메시지
if (controller.errorMessage != null)
Container(
margin: const EdgeInsets.only(top: 16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.red.shade300),
),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red.shade600, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
controller.errorMessage!,
style: TextStyle(color: Colors.red.shade600),
),
),
],
),
),
],
);
}
}

View File

@@ -102,10 +102,20 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
int? _getValidWarehouseId() {
if (_controller.selectedWarehouseId == null) return null;
// 데이터 로딩 중이면 선택한 값을 유지 (validation 스킵)
if (_controller.warehouses.isEmpty) {
print('DEBUG [_getValidWarehouseId] 데이터 로딩 중 - 선택한 값 유지: ${_controller.selectedWarehouseId}');
return _controller.selectedWarehouseId;
}
final isValid = _controller.warehouses.containsKey(_controller.selectedWarehouseId);
print('DEBUG [_getValidWarehouseId] selectedWarehouseId: ${_controller.selectedWarehouseId}, isValid: $isValid, available warehouses: ${_controller.warehouses.length}');
return isValid ? _controller.selectedWarehouseId : null;
// 유효하지 않더라도 선택한 값을 유지 (사용자 선택 존중)
if (!isValid) {
print('WARNING [_getValidWarehouseId] 선택한 창고가 목록에 없음 - 그래도 사용자 선택 유지');
}
return _controller.selectedWarehouseId;
}
Future<void> _onSave() async {
@@ -296,30 +306,49 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
),
const SizedBox(height: 16),
// 입고지 (드롭다운 전용)
ShadSelect<int>(
initialValue: _getValidWarehouseId(),
placeholder: const Text('입고지를 선택하세요'),
options: _controller.warehouses.entries.map((entry) =>
ShadOption(
value: entry.key,
child: Text(entry.value),
)
).toList(),
selectedOptionBuilder: (context, value) {
// warehouses가 비어있거나 해당 value가 없는 경우 처리
if (_controller.warehouses.isEmpty) {
return const Text('로딩중...');
}
return Text(_controller.warehouses[value] ?? '선택하세요');
},
onChanged: (value) {
setState(() {
_controller.selectedWarehouseId = value;
});
print('DEBUG [입고지 선택] value: $value, warehouses: ${_controller.warehouses.length}');
},
),
// 입고지 (수정 모드: 읽기 전용, 생성 모드: 선택 가능)
if (_controller.isEditMode)
// 수정 모드: 현재 창고 정보만 표시 (변경 불가)
ShadInputFormField(
readOnly: true,
placeholder: Text(_controller.warehouses.isNotEmpty && _controller.selectedWarehouseId != null
? '${_controller.warehouses[_controller.selectedWarehouseId!] ?? "창고 정보 없음"} 🔒'
: '창고 정보 로딩중... 🔒'),
label: const Text('입고지 * (수정 불가)'),
)
else
// 생성 모드: 창고 선택 가능
ShadSelect<int>(
initialValue: _getValidWarehouseId(),
placeholder: const Text('입고지를 선택하세요 *'),
options: _controller.warehouses.entries.map((entry) =>
ShadOption(
value: entry.key,
child: Text(entry.value),
)
).toList(),
selectedOptionBuilder: (context, value) {
// warehouses가 비어있거나 해당 value가 없는 경우 처리
if (_controller.warehouses.isEmpty) {
return const Text('로딩중...');
}
return Text(_controller.warehouses[value] ?? '선택하세요');
},
onChanged: (value) {
setState(() {
_controller.selectedWarehouseId = value;
});
print('✅ [입고지 선택] 선택한 값: $value');
print('📦 [입고지 선택] 사용 가능한 창고 수: ${_controller.warehouses.length}');
print('🔍 [입고지 선택] 최종 저장될 값: ${_controller.selectedWarehouseId}');
// 선택한 창고 이름도 출력
if (_controller.warehouses.isNotEmpty && value != null) {
final warehouseName = _controller.warehouses[value] ?? '알 수 없음';
print('🏪 [입고지 선택] 선택한 창고 이름: $warehouseName');
}
},
),
const SizedBox(height: 16),
// 초기 재고 수량 (신규 등록 시에만 표시)

View File

@@ -13,6 +13,13 @@ import 'package:superport/core/constants/app_constants.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/screens/equipment/widgets/equipment_history_dialog.dart';
import 'package:superport/screens/equipment/widgets/equipment_search_dialog.dart';
import 'package:superport/screens/equipment/dialogs/equipment_outbound_dialog.dart';
import 'package:superport/data/models/equipment/equipment_dto.dart';
import 'package:superport/domain/usecases/equipment/get_equipment_detail_usecase.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/data/repositories/equipment_history_repository.dart';
import 'package:superport/data/models/stock_status_dto.dart';
import 'package:superport/data/datasources/remote/api_client.dart';
/// shadcn/ui 스타일로 재설계된 장비 관리 화면
class EquipmentList extends StatefulWidget {
@@ -92,15 +99,15 @@ class _EquipmentListState extends State<EquipmentList> {
switch (widget.currentRoute) {
case Routes.equipmentInList:
_selectedStatus = 'in';
_controller.selectedStatusFilter = EquipmentStatus.in_;
_controller.selectedStatusFilter = 'I'; // 영문 코드 사용
break;
case Routes.equipmentOutList:
_selectedStatus = 'out';
_controller.selectedStatusFilter = EquipmentStatus.out;
_controller.selectedStatusFilter = 'O'; // 영문 코드 사용
break;
case Routes.equipmentRentList:
_selectedStatus = 'rent';
_controller.selectedStatusFilter = EquipmentStatus.rent;
_controller.selectedStatusFilter = 'T'; // 영문 코드 사용
break;
default:
_selectedStatus = 'all';
@@ -114,31 +121,31 @@ class _EquipmentListState extends State<EquipmentList> {
Future<void> _onStatusFilterChanged(String status) async {
setState(() {
_selectedStatus = status;
// 상태 필터를 EquipmentStatus 상수로 변환
// 상태 필터를 영문 코드로 변환
switch (status) {
case 'all':
_controller.selectedStatusFilter = null;
break;
case 'in':
_controller.selectedStatusFilter = EquipmentStatus.in_;
_controller.selectedStatusFilter = 'I';
break;
case 'out':
_controller.selectedStatusFilter = EquipmentStatus.out;
_controller.selectedStatusFilter = 'O';
break;
case 'rent':
_controller.selectedStatusFilter = EquipmentStatus.rent;
_controller.selectedStatusFilter = 'T';
break;
case 'repair':
_controller.selectedStatusFilter = EquipmentStatus.repair;
_controller.selectedStatusFilter = 'R';
break;
case 'damaged':
_controller.selectedStatusFilter = EquipmentStatus.damaged;
_controller.selectedStatusFilter = 'D';
break;
case 'lost':
_controller.selectedStatusFilter = EquipmentStatus.lost;
_controller.selectedStatusFilter = 'L';
break;
case 'disposed':
_controller.selectedStatusFilter = EquipmentStatus.disposed;
_controller.selectedStatusFilter = 'P';
break;
default:
_controller.selectedStatusFilter = null;
@@ -170,8 +177,17 @@ class _EquipmentListState extends State<EquipmentList> {
void _onSelectAll(bool? value) {
setState(() {
final equipments = _getFilteredEquipments();
for (final equipment in equipments) {
_controller.selectEquipment(equipment);
_selectedItems.clear(); // UI 체크박스 상태 초기화
if (value == true) {
for (final equipment in equipments) {
if (equipment.equipment.id != null) {
_selectedItems.add(equipment.equipment.id!);
_controller.selectEquipment(equipment);
}
}
} else {
_controller.clearSelection();
}
});
}
@@ -181,7 +197,7 @@ class _EquipmentListState extends State<EquipmentList> {
final equipments = _getFilteredEquipments();
if (equipments.isEmpty) return false;
return equipments.every((e) =>
_controller.selectedEquipmentIds.contains('${e.id}:${e.status}'));
_controller.selectedEquipmentIds.contains('${e.equipment.id}:${e.status}'));
}
@@ -221,20 +237,103 @@ class _EquipmentListState extends State<EquipmentList> {
return;
}
// 선택된 장비들의 요약 정보를 가져와서 출고 폼으로 전달
final selectedEquipmentsSummary = _controller.getSelectedEquipmentsSummary();
// ✅ 장비 수정과 동일한 방식: GetEquipmentDetailUseCase를 사용해서 완전한 데이터 로드
final selectedEquipmentIds = _controller.getSelectedEquipments()
.where((e) => e.status == 'I') // 영문 코드로 통일
.map((e) => e.equipment.id)
.where((id) => id != null)
.cast<int>()
.toList();
final result = await Navigator.pushNamed(
context,
Routes.equipmentOutAdd,
arguments: {'selectedEquipments': selectedEquipmentsSummary},
print('[EquipmentList] Loading complete equipment details for ${selectedEquipmentIds.length} equipments using GetEquipmentDetailUseCase');
// ✅ stock-status API를 사용해서 실제 현재 창고 정보가 포함된 데이터 로드
final selectedEquipments = <EquipmentDto>[];
final equipmentHistoryRepository = EquipmentHistoryRepositoryImpl(GetIt.instance<ApiClient>().dio);
// stock-status API를 시도하되, 실패해도 출고 프로세스 계속 진행
Map<int, StockStatusDto> stockStatusMap = {};
try {
// 1. 모든 재고 상태 정보를 한 번에 로드 (실패해도 계속 진행)
print('[EquipmentList] Attempting to load stock status...');
final stockStatusList = await equipmentHistoryRepository.getStockStatus();
for (final status in stockStatusList) {
stockStatusMap[status.equipmentId] = status;
}
print('[EquipmentList] Stock status loaded successfully: ${stockStatusMap.length} items');
} catch (e) {
print('[EquipmentList] ⚠️ Stock status API failed, continuing with basic equipment data: $e');
// 경고 메시지만 표시하고 계속 진행
ShadToaster.of(context).show(ShadToast(
title: const Text('알림'),
description: const Text('실시간 창고 정보를 가져올 수 없어 기본 정보로 진행합니다.'),
));
}
// 2. 각 장비의 상세 정보를 로드하고 가능하면 창고 정보를 매핑
final getEquipmentDetailUseCase = GetIt.instance<GetEquipmentDetailUseCase>();
for (final equipmentId in selectedEquipmentIds) {
print('[EquipmentList] Loading details for equipment $equipmentId');
final result = await getEquipmentDetailUseCase(equipmentId);
result.fold(
(failure) {
print('[EquipmentList] Failed to load equipment $equipmentId: ${failure.message}');
ShadToaster.of(context).show(ShadToast(
title: const Text('오류'),
description: Text('장비 정보를 불러오는데 실패했습니다: ${failure.message}'),
));
return; // 실패 시 종료
},
(equipment) {
// ✅ stock-status가 있으면 실제 창고 정보로 업데이트, 없으면 기존 정보 사용
final stockStatus = stockStatusMap[equipmentId];
EquipmentDto updatedEquipment = equipment;
if (stockStatus != null) {
updatedEquipment = equipment.copyWith(
warehousesId: stockStatus.warehouseId,
warehousesName: stockStatus.warehouseName,
);
print('[EquipmentList] ===== REAL WAREHOUSE DATA =====');
print('[EquipmentList] Equipment ID: $equipmentId');
print('[EquipmentList] Serial Number: ${equipment.serialNumber}');
print('[EquipmentList] REAL Warehouse ID: ${stockStatus.warehouseId}');
print('[EquipmentList] REAL Warehouse Name: ${stockStatus.warehouseName}');
print('[EquipmentList] =====================================');
} else {
print('[EquipmentList] ⚠️ No stock status found for equipment $equipmentId, using basic warehouse info');
print('[EquipmentList] Basic Warehouse ID: ${equipment.warehousesId}');
print('[EquipmentList] Basic Warehouse Name: ${equipment.warehousesName}');
}
selectedEquipments.add(updatedEquipment);
},
);
}
// 모든 장비 정보를 성공적으로 로드했는지 확인
if (selectedEquipments.length != selectedEquipmentIds.length) {
print('[EquipmentList] Failed to load complete equipment information');
return; // 일부 장비 정보 로드 실패 시 중단
}
// 출고 다이얼로그 표시
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return EquipmentOutboundDialog(
selectedEquipments: selectedEquipments,
);
},
);
if (result == true) {
setState(() {
_controller.loadData(isRefresh: true);
_controller.goToPage(1);
});
// 선택 상태 초기화 및 데이터 새로고침
_controller.clearSelection();
_controller.loadData(isRefresh: true);
}
}
@@ -262,7 +361,7 @@ class _EquipmentListState extends State<EquipmentList> {
/// 폐기 처리 버튼 핸들러
void _handleDisposeEquipment() async {
final selectedEquipments = _controller.getSelectedEquipments()
.where((equipment) => equipment.status != EquipmentStatus.disposed)
.where((equipment) => equipment.status != 'P') // 영문 코드로 통일
.toList();
if (selectedEquipments.isEmpty) {
@@ -865,7 +964,7 @@ class _EquipmentListState extends State<EquipmentList> {
totalWidth += 80; // 모델명 (100->80)
totalWidth += 70; // 장비번호 (90->70)
totalWidth += 50; // 상태 (60->50)
totalWidth += 90; // 관리 (120->90, 아이콘 전용으로 최적화)
totalWidth += 100; // 관리 (120->90->100, 아이콘 3개 수용)
// 중간 화면용 추가 컬럼들 (800px 이상)
if (availableWidth > 800) {
@@ -972,7 +1071,7 @@ class _EquipmentListState extends State<EquipmentList> {
// 상태
_buildHeaderCell('상태', flex: 2, useExpanded: useExpanded, minWidth: 50),
// 관리
_buildHeaderCell('관리', flex: 2, useExpanded: useExpanded, minWidth: 90),
_buildHeaderCell('관리', flex: 2, useExpanded: useExpanded, minWidth: 100),
// 중간 화면용 컬럼들 (800px 이상)
if (availableWidth > 800) ...[
@@ -1119,7 +1218,7 @@ class _EquipmentListState extends State<EquipmentList> {
child: const Icon(Icons.history, size: 16),
),
),
const SizedBox(width: 2),
const SizedBox(width: 1),
Tooltip(
message: '수정',
child: ShadButton.ghost(
@@ -1128,7 +1227,7 @@ class _EquipmentListState extends State<EquipmentList> {
child: const Icon(Icons.edit, size: 16),
),
),
const SizedBox(width: 2),
const SizedBox(width: 1),
Tooltip(
message: '삭제',
child: ShadButton.ghost(
@@ -1141,7 +1240,7 @@ class _EquipmentListState extends State<EquipmentList> {
),
flex: 2,
useExpanded: useExpanded,
minWidth: 90,
minWidth: 100,
),
// 중간 화면용 컬럼들 (800px 이상)
@@ -1332,7 +1431,7 @@ class _EquipmentListState extends State<EquipmentList> {
Widget _buildInventoryStatus(UnifiedEquipment equipment) {
// 백엔드 Equipment_History 기반으로 단순 상태만 표시
Widget stockInfo;
if (equipment.status == EquipmentStatus.in_) {
if (equipment.status == 'I') {
// 입고 상태: 재고 있음
stockInfo = Row(
mainAxisSize: MainAxisSize.min,
@@ -1345,7 +1444,7 @@ class _EquipmentListState extends State<EquipmentList> {
),
],
);
} else if (equipment.status == EquipmentStatus.out) {
} else if (equipment.status == 'O') {
// 출고 상태: 재고 없음
stockInfo = Row(
mainAxisSize: MainAxisSize.min,
@@ -1358,7 +1457,7 @@ class _EquipmentListState extends State<EquipmentList> {
),
],
);
} else if (equipment.status == EquipmentStatus.rent) {
} else if (equipment.status == 'T') {
// 대여 상태
stockInfo = Row(
mainAxisSize: MainAxisSize.min,
@@ -1387,19 +1486,36 @@ class _EquipmentListState extends State<EquipmentList> {
String displayText;
ShadcnBadgeVariant variant;
// 영문 코드만 사용 (EquipmentStatus 상수들도 실제로는 'I', 'O' 등의 값)
switch (status) {
case EquipmentStatus.in_:
case 'I':
displayText = '입고';
variant = ShadcnBadgeVariant.success;
break;
case EquipmentStatus.out:
case 'O':
displayText = '출고';
variant = ShadcnBadgeVariant.destructive;
break;
case EquipmentStatus.rent:
case 'T':
displayText = '대여';
variant = ShadcnBadgeVariant.warning;
break;
case 'R':
displayText = '수리';
variant = ShadcnBadgeVariant.secondary;
break;
case 'D':
displayText = '손상';
variant = ShadcnBadgeVariant.destructive;
break;
case 'L':
displayText = '분실';
variant = ShadcnBadgeVariant.destructive;
break;
case 'P':
displayText = '폐기';
variant = ShadcnBadgeVariant.secondary;
break;
default:
displayText = '알수없음';
variant = ShadcnBadgeVariant.secondary;
@@ -1501,11 +1617,19 @@ class _EquipmentListState extends State<EquipmentList> {
/// 체크박스 선택 관련 함수들
void _onItemSelected(int id, bool selected) {
// 해당 장비 찾기
final equipment = _controller.equipments.firstWhere(
(e) => e.equipment.id == id,
orElse: () => throw Exception('Equipment not found'),
);
setState(() {
if (selected) {
_selectedItems.add(id);
_controller.selectEquipment(equipment); // Controller에도 전달
} else {
_selectedItems.remove(id);
_controller.toggleSelection(equipment); // 선택 해제
}
});
}

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,
);
},
),
);
}
}

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:superport/data/models/maintenance_dto.dart';
import 'package:superport/data/models/equipment_history_dto.dart';
import 'package:superport/data/repositories/equipment_history_repository.dart';
import 'package:superport/domain/usecases/maintenance_usecase.dart';
/// 정비 우선순위
@@ -36,12 +38,16 @@ class MaintenanceSchedule {
/// 유지보수 컨트롤러 (백엔드 API 완전 호환)
class MaintenanceController extends ChangeNotifier {
final MaintenanceUseCase _maintenanceUseCase;
final EquipmentHistoryRepository _equipmentHistoryRepository;
// 상태 관리
List<MaintenanceDto> _maintenances = [];
bool _isLoading = false;
String? _error;
// EquipmentHistory 캐시 (성능 최적화)
final Map<int, EquipmentHistoryDto> _equipmentHistoryCache = {};
// 페이지네이션
int _currentPage = 1;
int _totalCount = 0;
@@ -69,8 +75,11 @@ class MaintenanceController extends ChangeNotifier {
// Form 상태
bool _isFormLoading = false;
MaintenanceController({required MaintenanceUseCase maintenanceUseCase})
: _maintenanceUseCase = maintenanceUseCase;
MaintenanceController({
required MaintenanceUseCase maintenanceUseCase,
required EquipmentHistoryRepository equipmentHistoryRepository,
}) : _maintenanceUseCase = maintenanceUseCase,
_equipmentHistoryRepository = equipmentHistoryRepository;
// Getters
List<MaintenanceDto> get maintenances => _maintenances;
@@ -124,6 +133,12 @@ class MaintenanceController extends ChangeNotifier {
_totalCount = response.totalCount;
_totalPages = response.totalPages;
// TODO: V/R 시스템에서는 maintenance API에서 직접 company_name 제공
// 기존 equipment-history 개별 호출 비활성화
// if (_maintenances.isNotEmpty) {
// preloadEquipmentData();
// }
} catch (e) {
_error = e.toString();
} finally {
@@ -452,12 +467,10 @@ class MaintenanceController extends ChangeNotifier {
String _getMaintenanceTypeDisplayName(String maintenanceType) {
switch (maintenanceType) {
case 'WARRANTY':
return '무상보증';
case 'CONTRACT':
return '유상계약';
case 'INSPECTION':
return '점검';
case 'V':
return '방문';
case 'R':
return '원격';
default:
return maintenanceType;
}
@@ -572,9 +585,93 @@ class MaintenanceController extends ChangeNotifier {
// 통계 정보
int get activeMaintenanceCount => _maintenances.where((m) => m.isActive).length;
int get expiredMaintenanceCount => _maintenances.where((m) => m.isExpired).length;
int get warrantyMaintenanceCount => _maintenances.where((m) => m.maintenanceType == 'WARRANTY').length;
int get contractMaintenanceCount => _maintenances.where((m) => m.maintenanceType == 'CONTRACT').length;
int get inspectionMaintenanceCount => _maintenances.where((m) => m.maintenanceType == 'INSPECTION').length;
int get visitMaintenanceCount => _maintenances.where((m) => m.maintenanceType == 'V').length;
int get remoteMaintenanceCount => _maintenances.where((m) => m.maintenanceType == 'R').length;
// Equipment 정보 조회 (캐시 지원)
Future<EquipmentHistoryDto?> getEquipmentHistoryForMaintenance(MaintenanceDto maintenance) async {
if (maintenance.equipmentHistoryId == null) return null;
final equipmentHistoryId = maintenance.equipmentHistoryId!;
// 캐시에서 먼저 확인
if (_equipmentHistoryCache.containsKey(equipmentHistoryId)) {
return _equipmentHistoryCache[equipmentHistoryId];
}
try {
// API에서 조회
final equipmentHistory = await _equipmentHistoryRepository.getEquipmentHistoryById(equipmentHistoryId);
// 캐시에 저장
_equipmentHistoryCache[equipmentHistoryId] = equipmentHistory;
return equipmentHistory;
} catch (e) {
debugPrint('Equipment History 조회 실패: $e');
return null;
}
}
// 장비명 조회 (UI용 헬퍼)
String getEquipmentName(MaintenanceDto maintenance) {
// 백엔드에서 직접 제공하는 equipment_model 사용
if (maintenance.equipmentModel != null && maintenance.equipmentModel!.isNotEmpty) {
return maintenance.equipmentModel!;
}
return 'Equipment #${maintenance.equipmentHistoryId ?? 'N/A'}';
}
// 시리얼번호 조회 (UI용 헬퍼)
String getEquipmentSerial(MaintenanceDto maintenance) {
// 백엔드에서 직접 제공하는 equipment_serial 사용
if (maintenance.equipmentSerial != null && maintenance.equipmentSerial!.isNotEmpty) {
return maintenance.equipmentSerial!;
}
return '-';
}
// 고객사명 조회 (UI용 헬퍼)
String getCompanyName(MaintenanceDto maintenance) {
// 백엔드에서 직접 제공하는 company_name 사용
debugPrint('getCompanyName - ID: ${maintenance.id}, companyName: "${maintenance.companyName}", companyId: ${maintenance.companyId}');
if (maintenance.companyName != null && maintenance.companyName!.isNotEmpty) {
return maintenance.companyName!;
}
return '-';
}
// 특정 maintenance의 equipment 정보가 로드되었는지 확인
bool isEquipmentDataLoaded(MaintenanceDto maintenance) {
return maintenance.equipmentHistoryId != null &&
_equipmentHistoryCache.containsKey(maintenance.equipmentHistoryId!);
}
// 모든 maintenance의 equipment 정보 미리 로드
Future<void> preloadEquipmentData() async {
final maintenancesWithHistoryId = _maintenances
.where((m) => m.equipmentHistoryId != null && !_equipmentHistoryCache.containsKey(m.equipmentHistoryId!))
.toList();
if (maintenancesWithHistoryId.isEmpty) return;
// 동시에 최대 5개씩만 로드 (API 부하 방지)
const batchSize = 5;
for (int i = 0; i < maintenancesWithHistoryId.length; i += batchSize) {
final batch = maintenancesWithHistoryId
.skip(i)
.take(batchSize)
.toList();
await Future.wait(
batch.map((maintenance) => getEquipmentHistoryForMaintenance(maintenance)),
);
// UI 업데이트
notifyListeners();
}
}
// 오류 관리
void clearError() {
@@ -601,6 +698,7 @@ class MaintenanceController extends ChangeNotifier {
_error = null;
_isLoading = false;
_isFormLoading = false;
_equipmentHistoryCache.clear(); // 캐시도 초기화
notifyListeners();
}

View File

@@ -0,0 +1,244 @@
import 'package:flutter/material.dart';
import 'package:superport/data/models/maintenance_stats_dto.dart';
import 'package:superport/domain/usecases/get_maintenance_stats_usecase.dart';
/// 유지보수 대시보드 컨트롤러
/// 60일내, 30일내, 7일내, 만료된 계약 통계를 관리합니다.
class MaintenanceDashboardController extends ChangeNotifier {
final GetMaintenanceStatsUseCase _getMaintenanceStatsUseCase;
MaintenanceDashboardController({
required GetMaintenanceStatsUseCase getMaintenanceStatsUseCase,
}) : _getMaintenanceStatsUseCase = getMaintenanceStatsUseCase;
// === 상태 관리 ===
MaintenanceStatsDto _stats = const MaintenanceStatsDto();
List<MaintenanceStatusCardData> _dashboardCards = [];
bool _isLoading = false;
bool _isRefreshing = false;
String? _errorMessage;
DateTime? _lastUpdated;
// === Getters ===
MaintenanceStatsDto get stats => _stats;
List<MaintenanceStatusCardData> get dashboardCards => _dashboardCards;
bool get isLoading => _isLoading;
bool get isRefreshing => _isRefreshing;
String? get errorMessage => _errorMessage;
DateTime? get lastUpdated => _lastUpdated;
// === 대시보드 카드 상태별 조회 ===
/// 60일 내 만료 예정 계약 카드 데이터
MaintenanceStatusCardData get expiring60DaysCard => MaintenanceStatusCardData(
title: '60일 내',
count: _stats.expiring60Days,
subtitle: '만료 예정',
status: _stats.expiring60Days > 0
? MaintenanceCardStatus.warning
: MaintenanceCardStatus.active,
actionLabel: '계획하기',
);
/// 30일 내 만료 예정 계약 카드 데이터
MaintenanceStatusCardData get expiring30DaysCard => MaintenanceStatusCardData(
title: '30일 내',
count: _stats.expiring30Days,
subtitle: '만료 예정',
status: _stats.expiring30Days > 0
? MaintenanceCardStatus.urgent
: MaintenanceCardStatus.active,
actionLabel: '예약하기',
);
/// 7일 내 만료 예정 계약 카드 데이터
MaintenanceStatusCardData get expiring7DaysCard => MaintenanceStatusCardData(
title: '7일 내',
count: _stats.expiring7Days,
subtitle: '만료 임박',
status: _stats.expiring7Days > 0
? MaintenanceCardStatus.critical
: MaintenanceCardStatus.active,
actionLabel: '즉시 처리',
);
/// 만료된 계약 카드 데이터
MaintenanceStatusCardData get expiredContractsCard => MaintenanceStatusCardData(
title: '만료됨',
count: _stats.expiredContracts,
subtitle: '조치 필요',
status: _stats.expiredContracts > 0
? MaintenanceCardStatus.expired
: MaintenanceCardStatus.active,
actionLabel: '갱신하기',
);
// === 추가 통계 정보 ===
/// 총 위험도 점수 (0.0 ~ 1.0)
double get riskScore => _stats.riskScore;
/// 위험도 상태
MaintenanceCardStatus get riskStatus => _stats.riskStatus;
/// 위험도 설명
String get riskDescription {
switch (riskStatus) {
case MaintenanceCardStatus.critical:
return '높은 위험 - 즉시 조치 필요';
case MaintenanceCardStatus.urgent:
return '중간 위험 - 빠른 대응 필요';
case MaintenanceCardStatus.warning:
return '낮은 위험 - 주의 관찰';
default:
return '안전 상태';
}
}
/// 매출 위험 금액 (포맷된 문자열)
String get formattedRevenueAtRisk {
final amount = _stats.totalRevenueAtRisk;
if (amount >= 1000000) {
return '${(amount / 1000000).toStringAsFixed(1)}백만원';
} else if (amount >= 10000) {
return '${(amount / 10000).toStringAsFixed(0)}만원';
} else {
return '${amount.toStringAsFixed(0)}';
}
}
/// 완료율 (백분율 문자열)
String get formattedCompletionRate {
return '${(_stats.completionRate * 100).toStringAsFixed(1)}%';
}
// === 데이터 로딩 메서드 ===
/// 대시보드 통계 초기 로딩
Future<void> loadDashboardStats() async {
if (_isLoading) return; // 중복 호출 방지
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
_stats = await _getMaintenanceStatsUseCase.getMaintenanceStats();
_dashboardCards = _stats.dashboardCards;
_lastUpdated = DateTime.now();
_errorMessage = null;
} catch (e) {
_errorMessage = e.toString();
// 오류 발생 시 기본값 설정 (UX 개선)
_stats = const MaintenanceStatsDto();
_dashboardCards = [];
debugPrint('대시보드 통계 로딩 오류: $e');
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 데이터 새로고침 (Pull-to-Refresh)
Future<void> refreshDashboardStats() async {
if (_isRefreshing) return;
_isRefreshing = true;
_errorMessage = null;
notifyListeners();
try {
_stats = await _getMaintenanceStatsUseCase.getMaintenanceStats();
_dashboardCards = _stats.dashboardCards;
_lastUpdated = DateTime.now();
_errorMessage = null;
} catch (e) {
_errorMessage = e.toString();
debugPrint('대시보드 통계 새로고침 오류: $e');
} finally {
_isRefreshing = false;
notifyListeners();
}
}
/// 특정 기간의 만료 예정 계약 수 조회
Future<int> getExpiringCount(int days) async {
try {
return await _getMaintenanceStatsUseCase.getExpiringContractsCount(days: days);
} catch (e) {
debugPrint('만료 예정 계약 조회 오류 ($days일): $e');
return 0;
}
}
/// 계약 타입별 통계 조회
Future<Map<String, int>> getContractsByType() async {
try {
return await _getMaintenanceStatsUseCase.getContractsByType();
} catch (e) {
debugPrint('계약 타입별 통계 조회 오류: $e');
return {'V': 0, 'R': 0};
}
}
// === 오류 처리 및 재시도 ===
/// 오류 메시지 초기화
void clearError() {
_errorMessage = null;
notifyListeners();
}
/// 재시도 (오류 발생 후)
Future<void> retry() async {
await loadDashboardStats();
}
// === 유틸리티 메서드 ===
/// 통계 데이터가 유효한지 확인
bool get hasValidData => _stats.updatedAt != null;
/// 마지막 업데이트 이후 경과 시간
String get timeSinceLastUpdate {
if (_lastUpdated == null) return '업데이트 없음';
final now = DateTime.now();
final difference = now.difference(_lastUpdated!);
if (difference.inMinutes < 1) {
return '방금 전';
} else if (difference.inMinutes < 60) {
return '${difference.inMinutes}분 전';
} else if (difference.inHours < 24) {
return '${difference.inHours}시간 전';
} else {
return '${difference.inDays}일 전';
}
}
/// 데이터 새로고침이 필요한지 확인 (5분 기준)
bool get needsRefresh {
if (_lastUpdated == null) return true;
return DateTime.now().difference(_lastUpdated!).inMinutes > 5;
}
/// 자동 새로고침 (필요 시에만)
Future<void> autoRefreshIfNeeded() async {
if (needsRefresh && !_isLoading && !_isRefreshing) {
await refreshDashboardStats();
}
}
// === 정리 메서드 ===
@override
void dispose() {
// 필요한 경우 타이머나 구독 해제
super.dispose();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ import 'package:superport/screens/common/widgets/standard_states.dart';
import 'package:superport/screens/maintenance/controllers/maintenance_controller.dart';
import 'package:superport/screens/maintenance/maintenance_form_dialog.dart';
import 'package:superport/data/models/maintenance_dto.dart';
import 'package:superport/data/repositories/equipment_history_repository.dart';
import 'package:superport/domain/usecases/maintenance_usecase.dart';
/// shadcn/ui 스타일로 설계된 유지보수 관리 화면
@@ -31,6 +32,7 @@ class _MaintenanceListState extends State<MaintenanceList> {
super.initState();
_controller = MaintenanceController(
maintenanceUseCase: GetIt.instance<MaintenanceUseCase>(),
equipmentHistoryRepository: GetIt.instance<EquipmentHistoryRepository>(),
);
// 초기 데이터 로드
@@ -464,11 +466,9 @@ class _MaintenanceListState extends State<MaintenanceList> {
// 유틸리티 메서드들
Color _getMaintenanceTypeColor(String type) {
switch (type) {
case MaintenanceType.warranty:
case MaintenanceType.visit:
return Colors.blue;
case MaintenanceType.contract:
return Colors.orange;
case MaintenanceType.inspection:
case MaintenanceType.remote:
return Colors.green;
default:
return Colors.grey;

View File

@@ -0,0 +1,405 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport/data/models/maintenance_stats_dto.dart';
import 'package:superport/screens/common/theme_shadcn.dart';
/// 유지보수 대시보드 상태 요약 카드
/// 60일내, 30일내, 7일내, 만료된 계약 통계를 표시합니다.
class StatusSummaryCards extends StatelessWidget {
final MaintenanceStatsDto stats;
final bool isLoading;
final String? error;
final VoidCallback? onRetry;
final Function(String)? onCardTap; // 카드 탭 시 호출 (카드 타입 전달)
const StatusSummaryCards({
super.key,
required this.stats,
this.isLoading = false,
this.error,
this.onRetry,
this.onCardTap,
});
@override
Widget build(BuildContext context) {
// 로딩 상태
if (isLoading) {
return _buildLoadingCards();
}
// 에러 상태
if (error != null) {
return _buildErrorCard();
}
// 정상 상태 - 4개 카드 표시
return _buildNormalCards();
}
/// 로딩 상태 카드들
Widget _buildLoadingCards() {
return Row(
children: List.generate(4, (index) => Expanded(
child: Container(
margin: EdgeInsets.only(right: index < 3 ? 16 : 0),
child: ShadCard(
child: const Padding(
padding: EdgeInsets.all(20),
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
),
),
)),
);
}
/// 에러 상태 카드
Widget _buildErrorCard() {
return ShadCard(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Icon(
Icons.error_outline,
size: 48,
color: ShadcnTheme.destructive,
),
const SizedBox(height: 16),
Text(
'통계 로딩 실패',
style: ShadcnTheme.headingH3.copyWith(
color: ShadcnTheme.destructive,
),
),
const SizedBox(height: 8),
Text(
error ?? '알 수 없는 오류가 발생했습니다',
style: ShadcnTheme.bodyLarge.copyWith(
color: ShadcnTheme.mutedForeground,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
if (onRetry != null)
ShadButton(
onPressed: onRetry,
child: const Text('다시 시도'),
),
],
),
),
);
}
/// 정상 상태 카드들
Widget _buildNormalCards() {
final cardData = [
_CardData(
title: '60일 내',
count: stats.expiring60Days,
subtitle: '만료 예정',
icon: Icons.schedule_outlined,
color: _getStatusColor(MaintenanceCardStatus.warning),
status: stats.expiring60Days > 0
? MaintenanceCardStatus.warning
: MaintenanceCardStatus.active,
actionLabel: '계획하기',
cardType: 'expiring_60',
),
_CardData(
title: '30일 내',
count: stats.expiring30Days,
subtitle: '만료 예정',
icon: Icons.warning_amber_outlined,
color: _getStatusColor(MaintenanceCardStatus.urgent),
status: stats.expiring30Days > 0
? MaintenanceCardStatus.urgent
: MaintenanceCardStatus.active,
actionLabel: '예약하기',
cardType: 'expiring_30',
),
_CardData(
title: '7일 내',
count: stats.expiring7Days,
subtitle: '만료 임박',
icon: Icons.priority_high_outlined,
color: _getStatusColor(MaintenanceCardStatus.critical),
status: stats.expiring7Days > 0
? MaintenanceCardStatus.critical
: MaintenanceCardStatus.active,
actionLabel: '즉시 처리',
cardType: 'expiring_7',
),
_CardData(
title: '만료됨',
count: stats.expiredContracts,
subtitle: '조치 필요',
icon: Icons.error_outline,
color: _getStatusColor(MaintenanceCardStatus.expired),
status: stats.expiredContracts > 0
? MaintenanceCardStatus.expired
: MaintenanceCardStatus.active,
actionLabel: '갱신하기',
cardType: 'expired',
),
];
return Row(
children: cardData.asMap().entries.map((entry) {
int index = entry.key;
_CardData card = entry.value;
return Expanded(
child: Container(
margin: EdgeInsets.only(right: index < 3 ? 16 : 0),
child: _buildMaintenanceCard(card),
),
);
}).toList(),
);
}
/// 단일 유지보수 카드 빌더
Widget _buildMaintenanceCard(_CardData cardData) {
return ShadCard(
child: InkWell(
onTap: () => onCardTap?.call(cardData.cardType),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 헤더 (아이콘 + 상태 인디케이터)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Icon(
cardData.icon,
size: 28,
color: cardData.color,
),
_buildStatusIndicator(cardData.status),
],
),
const SizedBox(height: 16),
// 제목
Text(
cardData.title,
style: ShadcnTheme.bodyLarge.copyWith(
color: ShadcnTheme.mutedForeground,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
// 개수 (메인 메트릭)
Text(
cardData.count.toString(),
style: ShadcnTheme.headingH1.copyWith(
color: cardData.color,
fontWeight: FontWeight.bold,
fontSize: 32,
),
),
const SizedBox(height: 4),
// 부제목
Text(
cardData.subtitle,
style: ShadcnTheme.bodyMedium.copyWith(
color: ShadcnTheme.mutedForeground,
),
),
const SizedBox(height: 16),
// 액션 버튼 (조건부 표시)
if (cardData.count > 0 && cardData.actionLabel != null)
SizedBox(
width: double.infinity,
child: ShadButton.outline(
onPressed: () => onCardTap?.call(cardData.cardType),
size: ShadButtonSize.sm,
child: Text(
cardData.actionLabel!,
style: TextStyle(
fontSize: 12,
color: cardData.color,
),
),
),
),
],
),
),
),
);
}
/// 상태 인디케이터
Widget _buildStatusIndicator(MaintenanceCardStatus status) {
Color color;
IconData icon;
switch (status) {
case MaintenanceCardStatus.critical:
color = Colors.red;
icon = Icons.circle;
break;
case MaintenanceCardStatus.urgent:
color = Colors.orange;
icon = Icons.circle;
break;
case MaintenanceCardStatus.warning:
color = Colors.amber;
icon = Icons.circle;
break;
case MaintenanceCardStatus.expired:
color = Colors.red.shade800;
icon = Icons.circle;
break;
case MaintenanceCardStatus.active:
color = Colors.green;
icon = Icons.circle;
break;
}
return Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.3),
blurRadius: 4,
spreadRadius: 1,
),
],
),
);
}
/// 상태별 색상 반환
Color _getStatusColor(MaintenanceCardStatus status) {
switch (status) {
case MaintenanceCardStatus.critical:
return Colors.red.shade600;
case MaintenanceCardStatus.urgent:
return Colors.orange.shade600;
case MaintenanceCardStatus.warning:
return Colors.amber.shade600;
case MaintenanceCardStatus.expired:
return Colors.red.shade800;
case MaintenanceCardStatus.active:
return Colors.green.shade600;
}
}
}
/// 모바일 대응 스택 레이아웃 (세로 카드 배치)
class StatusSummaryCardsStack extends StatelessWidget {
final MaintenanceStatsDto stats;
final bool isLoading;
final String? error;
final VoidCallback? onRetry;
final Function(String)? onCardTap;
const StatusSummaryCardsStack({
super.key,
required this.stats,
this.isLoading = false,
this.error,
this.onRetry,
this.onCardTap,
});
@override
Widget build(BuildContext context) {
// 모바일에서는 2x2 그리드로 표시
return Column(
children: [
Row(
children: [
Expanded(
child: StatusSummaryCards(
stats: MaintenanceStatsDto(expiring60Days: stats.expiring60Days),
isLoading: isLoading,
error: error,
onRetry: onRetry,
onCardTap: onCardTap,
),
),
const SizedBox(width: 16),
Expanded(
child: StatusSummaryCards(
stats: MaintenanceStatsDto(expiring30Days: stats.expiring30Days),
isLoading: isLoading,
error: error,
onRetry: onRetry,
onCardTap: onCardTap,
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: StatusSummaryCards(
stats: MaintenanceStatsDto(expiring7Days: stats.expiring7Days),
isLoading: isLoading,
error: error,
onRetry: onRetry,
onCardTap: onCardTap,
),
),
const SizedBox(width: 16),
Expanded(
child: StatusSummaryCards(
stats: MaintenanceStatsDto(expiredContracts: stats.expiredContracts),
isLoading: isLoading,
error: error,
onRetry: onRetry,
onCardTap: onCardTap,
),
),
],
),
],
);
}
}
/// 카드 데이터 모델 (내부 사용)
class _CardData {
final String title;
final int count;
final String subtitle;
final IconData icon;
final Color color;
final MaintenanceCardStatus status;
final String? actionLabel;
final String cardType;
const _CardData({
required this.title,
required this.count,
required this.subtitle,
required this.icon,
required this.color,
required this.status,
this.actionLabel,
required this.cardType,
});
}