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

@@ -48,8 +48,9 @@ abstract class EquipmentHistoryRepository {
Future<EquipmentHistoryDto> createStockOut({
required int equipmentsId,
required int warehousesId,
int? warehousesId, // 출고 시 null (다른 회사로 완전 이관)
required int quantity,
List<int>? companyIds, // 출고 대상 회사 ID 목록
DateTime? transactedAt,
String? remark,
});
@@ -146,15 +147,37 @@ class EquipmentHistoryRepositoryImpl implements EquipmentHistoryRepository {
@override
Future<List<StockStatusDto>> getStockStatus() async {
try {
print('[EquipmentHistoryRepository] Stock Status API 호출 시작');
print('[EquipmentHistoryRepository] URL: ${_dio.options.baseUrl}${ApiEndpoints.equipmentHistoryStockStatus}');
print('[EquipmentHistoryRepository] Headers: ${_dio.options.headers}');
final response = await _dio.get(ApiEndpoints.equipmentHistoryStockStatus);
print('[EquipmentHistoryRepository] API 응답 수신');
print('[EquipmentHistoryRepository] Status Code: ${response.statusCode}');
print('[EquipmentHistoryRepository] Response Type: ${response.data.runtimeType}');
final List<dynamic> data = response.data is List
? response.data
: response.data['data'] ?? [];
return data.map((json) => StockStatusDto.fromJson(json)).toList();
print('[EquipmentHistoryRepository] 파싱된 데이터 개수: ${data.length}');
final result = data.map((json) => StockStatusDto.fromJson(json)).toList();
print('[EquipmentHistoryRepository] DTO 변환 완료: ${result.length}개 항목');
return result;
} on DioException catch (e) {
print('[EquipmentHistoryRepository] DioException 발생');
print('[EquipmentHistoryRepository] Error Type: ${e.type}');
print('[EquipmentHistoryRepository] Error Message: ${e.message}');
print('[EquipmentHistoryRepository] Response Status: ${e.response?.statusCode}');
throw _handleError(e);
} catch (e, stackTrace) {
print('[EquipmentHistoryRepository] 일반 Exception 발생');
print('[EquipmentHistoryRepository] Error: $e');
print('[EquipmentHistoryRepository] StackTrace: $stackTrace');
rethrow;
}
}
@@ -165,12 +188,30 @@ class EquipmentHistoryRepositoryImpl implements EquipmentHistoryRepository {
EquipmentHistoryRequestDto request,
) async {
try {
print('[EquipmentHistoryRepository] Creating equipment history');
print('[EquipmentHistoryRepository] URL: ${_dio.options.baseUrl}${ApiEndpoints.equipmentHistory}');
print('[EquipmentHistoryRepository] Headers: ${_dio.options.headers}');
print('[EquipmentHistoryRepository] Request data: ${request.toJson()}');
final response = await _dio.post(
ApiEndpoints.equipmentHistory,
data: request.toJson(),
);
print('[EquipmentHistoryRepository] Response status: ${response.statusCode}');
print('[EquipmentHistoryRepository] Response data: ${response.data}');
// 응답 데이터 타입 검증
if (response.data is String) {
print('[EquipmentHistoryRepository] Error: Received String response instead of JSON');
throw Exception('서버에서 오류 응답을 받았습니다: ${response.data}');
}
return EquipmentHistoryDto.fromJson(response.data);
} on DioException catch (e) {
print('[EquipmentHistoryRepository] DioException occurred');
print('[EquipmentHistoryRepository] Request URL: ${e.requestOptions.uri}');
print('[EquipmentHistoryRepository] Request headers: ${e.requestOptions.headers}');
throw _handleError(e);
}
}
@@ -213,7 +254,7 @@ class EquipmentHistoryRepositoryImpl implements EquipmentHistoryRepository {
warehousesId: warehousesId,
quantity: quantity,
transactionType: 'I', // 입고
transactedAt: transactedAt ?? DateTime.now(),
transactedAt: (transactedAt ?? DateTime.now()).toUtc(), // UTC로 변환하여 타임존 정보 포함
remark: remark,
);
@@ -223,8 +264,9 @@ class EquipmentHistoryRepositoryImpl implements EquipmentHistoryRepository {
@override
Future<EquipmentHistoryDto> createStockOut({
required int equipmentsId,
required int warehousesId,
int? warehousesId, // 출고 시 null (다른 회사로 완전 이관)
required int quantity,
List<int>? companyIds, // 출고 대상 회사 ID 목록
DateTime? transactedAt,
String? remark,
}) async {
@@ -232,9 +274,10 @@ class EquipmentHistoryRepositoryImpl implements EquipmentHistoryRepository {
final request = EquipmentHistoryRequestDto(
equipmentsId: equipmentsId,
warehousesId: warehousesId,
companyIds: companyIds, // 백엔드 API에 회사 정보 전달
quantity: quantity,
transactionType: 'O', // 출고
transactedAt: transactedAt ?? DateTime.now(),
transactedAt: (transactedAt ?? DateTime.now()).toUtc(), // UTC로 변환하여 타임존 정보 포함
remark: remark,
);
@@ -242,9 +285,31 @@ class EquipmentHistoryRepositoryImpl implements EquipmentHistoryRepository {
}
Exception _handleError(DioException e) {
print('[EquipmentHistoryRepository] _handleError called');
print('[EquipmentHistoryRepository] Error type: ${e.type}');
print('[EquipmentHistoryRepository] Error message: ${e.message}');
if (e.response != null) {
final statusCode = e.response!.statusCode;
final message = e.response!.data['message'] ?? '오류가 발생했습니다.';
print('[EquipmentHistoryRepository] Response status: $statusCode');
print('[EquipmentHistoryRepository] Response data type: ${e.response!.data.runtimeType}');
print('[EquipmentHistoryRepository] Response data: ${e.response!.data}');
// 안전한 메시지 추출
String message = '오류가 발생했습니다.';
try {
final responseData = e.response!.data;
if (responseData is Map<String, dynamic>) {
message = responseData['message']?.toString() ?? message;
} else if (responseData is String) {
message = responseData;
} else {
message = responseData.toString();
}
} catch (messageError) {
print('[EquipmentHistoryRepository] Message extraction error: $messageError');
message = '응답 처리 중 오류가 발생했습니다.';
}
switch (statusCode) {
case 400:
@@ -263,6 +328,6 @@ class EquipmentHistoryRepositoryImpl implements EquipmentHistoryRepository {
return Exception('오류가 발생했습니다: $message');
}
}
return Exception('네트워크 오류가 발생했습니다.');
return Exception('네트워크 오류가 발생했습니다: ${e.message}');
}
}

View File

@@ -0,0 +1,25 @@
import 'package:superport/data/models/maintenance_stats_dto.dart';
/// 유지보수 통계 데이터 리포지토리
/// 기존 maintenance API를 활용하여 대시보드 통계를 계산합니다.
abstract class MaintenanceStatsRepository {
/// 유지보수 대시보드 통계 조회
/// 60일내, 30일내, 7일내, 만료된 계약 등의 통계를 계산합니다.
Future<MaintenanceStatsDto> getMaintenanceStats();
/// 특정 기간별 만료 예정 계약 통계
/// [days] 일 내 만료 예정 계약 수를 반환합니다.
Future<int> getExpiringContractsCount({required int days});
/// 계약 타입별 통계
/// WARRANTY, CONTRACT, INSPECTION 별 계약 수를 반환합니다.
Future<Map<String, int>> getContractsByType();
/// 만료된 계약 통계
/// 현재 기준으로 만료된 계약 수를 반환합니다.
Future<int> getExpiredContractsCount();
/// 전체 활성 계약 수
/// 삭제되지 않은 활성 계약의 총 개수를 반환합니다.
Future<int> getActiveContractsCount();
}

View File

@@ -0,0 +1,223 @@
import 'package:injectable/injectable.dart';
import 'package:superport/core/errors/exceptions.dart';
import 'package:superport/core/errors/failures.dart';
import 'package:superport/data/datasources/remote/maintenance_remote_datasource.dart';
import 'package:superport/data/models/maintenance_dto.dart';
import 'package:superport/data/models/maintenance_stats_dto.dart';
import 'package:superport/data/repositories/maintenance_stats_repository.dart';
@LazySingleton(as: MaintenanceStatsRepository)
class MaintenanceStatsRepositoryImpl implements MaintenanceStatsRepository {
final MaintenanceRemoteDataSource _remoteDataSource;
MaintenanceStatsRepositoryImpl({
required MaintenanceRemoteDataSource remoteDataSource,
}) : _remoteDataSource = remoteDataSource;
@override
Future<MaintenanceStatsDto> getMaintenanceStats() async {
try {
// 모든 활성 계약 조회 (대용량 데이터를 위해 페이지 크기 증가)
final allMaintenances = await _getAllActiveMaintenances();
// 통계 계산
final stats = _calculateStats(allMaintenances);
return stats.copyWith(updatedAt: DateTime.now());
} on ServerException catch (e) {
throw ServerFailure(
message: e.message,
statusCode: e.statusCode,
);
} catch (e) {
throw ServerFailure(message: '유지보수 통계 조회 중 오류가 발생했습니다');
}
}
@override
Future<int> getExpiringContractsCount({required int days}) async {
try {
final expiringMaintenances = await _remoteDataSource.getExpiringMaintenances(days: days);
return expiringMaintenances.length;
} on ServerException catch (e) {
throw ServerFailure(
message: e.message,
statusCode: e.statusCode,
);
} catch (e) {
throw ServerFailure(message: '만료 예정 계약 조회 중 오류가 발생했습니다');
}
}
@override
Future<Map<String, int>> getContractsByType() async {
try {
final allMaintenances = await _getAllActiveMaintenances();
final contractsByType = <String, int>{
MaintenanceType.visit: 0,
MaintenanceType.remote: 0,
};
for (final maintenance in allMaintenances) {
final type = maintenance.maintenanceType;
contractsByType[type] = (contractsByType[type] ?? 0) + 1;
}
return contractsByType;
} on ServerException catch (e) {
throw ServerFailure(
message: e.message,
statusCode: e.statusCode,
);
} catch (e) {
throw ServerFailure(message: '계약 타입별 통계 조회 중 오류가 발생했습니다');
}
}
@override
Future<int> getExpiredContractsCount() async {
try {
// 만료된 계약 조회 (is_expired = true)
final expiredResponse = await _remoteDataSource.getMaintenances(
isExpired: true,
perPage: 1000, // 충분히 큰 값으로 설정
);
return expiredResponse.totalCount;
} on ServerException catch (e) {
throw ServerFailure(
message: e.message,
statusCode: e.statusCode,
);
} catch (e) {
throw ServerFailure(message: '만료된 계약 조회 중 오류가 발생했습니다');
}
}
@override
Future<int> getActiveContractsCount() async {
try {
// 활성 계약만 조회 (is_expired = false)
final activeResponse = await _remoteDataSource.getMaintenances(
isExpired: false,
perPage: 1, // 개수만 필요하므로 1개만 조회
);
return activeResponse.totalCount;
} on ServerException catch (e) {
throw ServerFailure(
message: e.message,
statusCode: e.statusCode,
);
} catch (e) {
throw ServerFailure(message: '활성 계약 조회 중 오류가 발생했습니다');
}
}
/// 모든 활성 계약을 페이지네이션으로 조회
Future<List<MaintenanceDto>> _getAllActiveMaintenances() async {
final allMaintenances = <MaintenanceDto>[];
int currentPage = 1;
const int perPage = 100; // 한 번에 많은 데이터 조회로 API 호출 최소화
while (true) {
final response = await _remoteDataSource.getMaintenances(
page: currentPage,
perPage: perPage,
isExpired: false, // 활성 계약만 조회
);
allMaintenances.addAll(response.items);
// 마지막 페이지 도달 시 종료
if (currentPage >= response.totalPages) {
break;
}
currentPage++;
}
return allMaintenances;
}
/// 유지보수 데이터를 기반으로 통계 계산
MaintenanceStatsDto _calculateStats(List<MaintenanceDto> maintenances) {
final now = DateTime.now();
// 기본 카운터 초기화
int expiring60Days = 0;
int expiring30Days = 0;
int expiring7Days = 0;
int expiredContracts = 0;
final contractsByType = <String, int>{
MaintenanceType.visit: 0,
MaintenanceType.remote: 0,
};
// 각 유지보수 계약별 통계 계산
for (final maintenance in maintenances) {
// 계약 타입별 카운트
final type = maintenance.maintenanceType;
contractsByType[type] = (contractsByType[type] ?? 0) + 1;
// 만료 상태 체크
if (maintenance.isExpired) {
expiredContracts++;
continue;
}
// 만료일까지 남은 일수 계산
final daysRemaining = maintenance.daysRemaining ?? 0;
if (daysRemaining <= 7) {
expiring7Days++;
} else if (daysRemaining <= 30) {
expiring30Days++;
} else if (daysRemaining <= 60) {
expiring60Days++;
}
}
// 총 계약 수 계산
final totalContracts = maintenances.length + expiredContracts;
final activeContracts = maintenances.length;
// 위험 수준의 계약들의 매출 위험도 추정 (간단한 계산)
final totalRevenueAtRisk = (expiring7Days * 500000.0) +
(expiring30Days * 300000.0) +
(expiredContracts * 1000000.0);
// 완료율 계산 (활성 계약 / 전체 계약)
final completionRate = totalContracts > 0
? (activeContracts / totalContracts)
: 1.0;
return MaintenanceStatsDto(
// 기본 통계
activeContracts: activeContracts,
totalContracts: totalContracts,
// 만료 기간별 통계 (사용자 요구사항)
expiring60Days: expiring60Days,
expiring30Days: expiring30Days,
expiring7Days: expiring7Days,
expiredContracts: expiredContracts,
// 타입별 통계 (V/R 시스템)
visitContracts: contractsByType[MaintenanceType.visit] ?? 0,
remoteContracts: contractsByType[MaintenanceType.remote] ?? 0,
// 예정 작업 (방문 계약과 동일하게 처리)
upcomingVisits: contractsByType[MaintenanceType.visit] ?? 0,
overdueMaintenances: expiredContracts,
// 추가 메트릭
totalRevenueAtRisk: totalRevenueAtRisk,
completionRate: completionRate,
updatedAt: now,
);
}
}