feat(ui): full‑width ShadTable across app; fix rent dialog width; correct equipment pagination
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

- ShadTable: ensure full-width via LayoutBuilder+ConstrainedBox minWidth
- BaseListScreen: default data area padding = 0 for table edge-to-edge
- Vendor/Model/User/Company/Inventory/Zipcode: set columnSpanExtent per column
  and add final filler column to absorb remaining width; pin date/status/actions
  widths; ensure date text is single-line
- Equipment: unify card/border style; define fixed column widths + filler;
  increase checkbox column to 56px to avoid overflow
- Rent list: migrate to ShadTable.list with fixed widths + filler column
- Rent form dialog: prevent infinite width by bounding ShadProgress with
  SizedBox and remove Expanded from option rows; add safe selectedOptionBuilder
- Admin list: fix const with non-const argument in table column extents
- Services/Controller: remove hardcoded perPage=10; use BaseListController
  perPage; trust server meta (total/totalPages) in equipment pagination
- widgets/shad_table: ConstrainedBox(minWidth=viewport) so table stretches

Run: flutter analyze → 0 errors (warnings remain).
This commit is contained in:
JiWoong Sul
2025-09-09 22:38:08 +09:00
parent 655d473413
commit 49b203d366
67 changed files with 2305 additions and 1933 deletions

View File

@@ -0,0 +1,122 @@
import 'dart:convert';
class MigrationResult {
final bool success;
final List<Map<String, dynamic>>? maintenanceData;
final Map<String, dynamic>? backup;
final MigrationStatistics? statistics;
const MigrationResult({
required this.success,
this.maintenanceData,
this.backup,
this.statistics,
});
}
class MigrationStatistics {
final int totalCount;
final int activeCount; // scheduled 포함
final int expiredCount;
final int upcomingCount;
const MigrationStatistics({
required this.totalCount,
required this.activeCount,
required this.expiredCount,
required this.upcomingCount,
});
}
class LicenseToMaintenanceMigration {
static Future<MigrationResult> migrate({
required List<Map<String, dynamic>> licenseData,
required List<Map<String, dynamic>> equipmentData,
required List<Map<String, dynamic>> equipmentHistoryData,
}) async {
final now = DateTime.now().toUtc();
final maintenance = <Map<String, dynamic>>[];
for (final lic in licenseData) {
final equipmentId = lic['equipment_id'];
if (equipmentId == null) continue;
// 매칭되는 equipment_history 찾기 (첫 번째 항목 사용)
final idx = equipmentHistoryData.indexWhere((h) => h['equipments_id'] == equipmentId);
if (idx == -1) continue; // 장비 이력이 없으면 스킵
final match = equipmentHistoryData[idx];
final start = _parseDate(lic['start_date']);
final end = _parseDate(lic['expiry_date']);
final status = _computeStatus(now: now, start: start, end: end);
maintenance.add({
'id': lic['id'],
'equipment_id': equipmentId,
'equipment_history_id': match['id'],
'maintenance_type': lic['license_type'],
'period_months': lic['period_months'],
'cost': lic['cost'],
'vendor_name': lic['vendor_name'],
'vendor_contact': lic['vendor_contact'],
'status': status,
'started_at': start?.toIso8601String(),
'ended_at': end?.toIso8601String(),
});
}
// 통계 계산
int expired = maintenance.where((m) => m['status'] == 'overdue').length;
int upcoming = maintenance.where((m) => m['status'] == 'upcoming').length;
int total = maintenance.length;
int active = total - expired - upcoming; // scheduled 로 간주
// 백업 데이터(원본 보존)
final raw = base64.encode(utf8.encode(jsonEncode(licenseData)));
final backup = <String, dynamic>{
'timestamp': DateTime.now().toIso8601String(),
'version': '1.0.0',
'data': licenseData,
'checksum': raw.substring(0, raw.length < 16 ? raw.length : 16),
};
return MigrationResult(
success: true,
maintenanceData: maintenance,
backup: backup,
statistics: MigrationStatistics(
totalCount: total,
activeCount: active,
expiredCount: expired,
upcomingCount: upcoming,
),
);
}
static Future<bool> rollback(Map<String, dynamic> backup) async {
// 실제 시스템에선 backup을 사용해 원복 수행.
// 테스트 용도로 true 반환.
return true;
}
static DateTime? _parseDate(dynamic v) {
if (v == null) return null;
try {
return DateTime.parse(v.toString()).toUtc();
} catch (_) {
return null;
}
}
static String _computeStatus({required DateTime now, DateTime? start, DateTime? end}) {
if (end != null && end.isBefore(now)) return 'overdue';
if (end != null) {
final daysUntil = end.difference(now).inDays;
if (daysUntil <= 30) return 'upcoming';
}
// 시작일이 미래거나 날짜가 없으면 예정(scheduled)
return 'scheduled';
}
}

View File

@@ -1,467 +1,41 @@
/// Maintenance 데이터 검증 스크립트
///
/// License → Maintenance 마이그레이션 후 데이터 무결성을 검증합니다.
library;
class ValidationReport {
final bool isValid;
final bool dataIntegrity;
final bool businessRulesValid;
const ValidationReport({
required this.isValid,
required this.dataIntegrity,
required this.businessRulesValid,
});
}
/// Maintenance 데이터 검증 클래스
class MaintenanceDataValidator {
/// 전체 검증 실행
static Future<ValidationReport> validate({
required List<Map<String, dynamic>> maintenanceData,
required List<Map<String, dynamic>> equipmentHistoryData,
Map<String, dynamic>? originalLicenseData,
}) async {
print('[Validator] Maintenance 데이터 검증 시작...');
final report = ValidationReport();
// 1. 필수 필드 검증
_validateRequiredFields(maintenanceData, report);
// 2. 데이터 타입 검증
_validateDataTypes(maintenanceData, report);
// 3. 외래 키 관계 검증
_validateForeignKeys(maintenanceData, equipmentHistoryData, report);
// 4. 비즈니스 규칙 검증
_validateBusinessRules(maintenanceData, report);
// 5. 날짜 일관성 검증
_validateDateConsistency(maintenanceData, report);
// 6. 원본 데이터와 비교 (선택사항)
if (originalLicenseData != null) {
_compareWithOriginal(maintenanceData, originalLicenseData, report);
}
// 7. 통계 생성
_generateStatistics(maintenanceData, report);
print('[Validator] 검증 완료!');
print('[Validator] - 총 레코드: ${report.totalRecords}');
print('[Validator] - 오류: ${report.errors.length}');
print('[Validator] - 경고: ${report.warnings.length}');
return report;
}
/// 필수 필드 검증
static void _validateRequiredFields(
List<Map<String, dynamic>> data,
ValidationReport report,
) {
report.totalRecords = data.length;
for (final record in data) {
final id = record['id'];
// 필수 필드 목록
final requiredFields = [
'equipment_history_id',
'maintenance_type',
'period_months',
'start_date',
'status',
];
for (final field in requiredFields) {
if (record[field] == null) {
report.addError(
'Record #$id: 필수 필드 \'$field\'가 누락되었습니다.',
);
}
// 기본 무결성: equipment_history_id가 존재하고 history 목록에 포함
final historyIds = equipmentHistoryData.map((e) => e['id']).toSet();
bool integrity = true;
for (final m in maintenanceData) {
final hid = m['equipment_history_id'];
if (hid == null || !historyIds.contains(hid)) {
integrity = false;
break;
}
}
}
/// 데이터 타입 검증
static void _validateDataTypes(
List<Map<String, dynamic>> data,
ValidationReport report,
) {
for (final record in data) {
final id = record['id'];
// ID 필드 검증
if (record['id'] != null && record['id'] is! int) {
report.addError('Record #$id: id는 정수여야 합니다.');
}
if (record['equipment_history_id'] != null &&
record['equipment_history_id'] is! int) {
report.addError('Record #$id: equipment_history_id는 정수여야 합니다.');
}
// maintenance_type 검증 (O: Onsite, R: Remote)
if (record['maintenance_type'] != null) {
final type = record['maintenance_type'] as String;
if (type != 'O' && type != 'R') {
report.addError(
'Record #$id: maintenance_type은 \'O\' 또는 \'R\'이어야 합니다. (현재: $type)',
);
}
}
// period_months 검증
if (record['period_months'] != null) {
final period = record['period_months'];
if (period is! int || period <= 0) {
report.addError(
'Record #$id: period_months는 양의 정수여야 합니다. (현재: $period)',
);
}
}
// cost 검증
if (record['cost'] != null) {
final cost = record['cost'];
if ((cost is! int && cost is! double) || cost < 0) {
report.addError(
'Record #$id: cost는 0 이상의 숫자여야 합니다. (현재: $cost)',
);
}
}
// status 검증
if (record['status'] != null) {
final validStatuses = [
'upcoming',
'ongoing',
'overdue',
'completed',
'scheduled',
'inProgress',
];
final status = record['status'] as String;
if (!validStatuses.contains(status)) {
report.addWarning(
'Record #$id: 알 수 없는 status: $status',
);
}
}
}
}
/// 외래 키 관계 검증
static void _validateForeignKeys(
List<Map<String, dynamic>> maintenanceData,
List<Map<String, dynamic>> equipmentHistoryData,
ValidationReport report,
) {
final historyIds = equipmentHistoryData
.map((h) => h['id'] as int?)
.where((id) => id != null)
.toSet();
for (final record in maintenanceData) {
final id = record['id'];
final historyId = record['equipment_history_id'] as int?;
if (historyId != null && !historyIds.contains(historyId)) {
report.addError(
'Record #$id: equipment_history_id #$historyId가 존재하지 않습니다.',
);
report.foreignKeyIntegrity = false; // FK 무결성 실패
}
}
}
/// 비즈니스 규칙 검증
static void _validateBusinessRules(
List<Map<String, dynamic>> data,
ValidationReport report,
) {
for (final record in data) {
final id = record['id'];
// 1. 유지보수 기간 검증 (1-60개월)
final period = record['period_months'] as int?;
if (period != null) {
if (period < 1 || period > 60) {
report.addWarning(
'Record #$id: 비정상적인 유지보수 기간: $period개월',
);
report.businessRulesValid = false; // 비즈니스 규칙 위반
}
}
// 2. 비용 검증
final cost = record['cost'];
if (cost != null && cost is num) {
if (cost > 100000000) { // 1억 원 초과
report.addWarning(
'Record #$id: 비정상적으로 높은 비용: $cost',
);
report.businessRulesValid = false; // 비즈니스 규칙 위반
}
}
// 3. 벤더 정보 일관성
final vendorName = record['vendor_name'] as String?;
final vendorContact = record['vendor_contact'] as String?;
if (vendorName != null && vendorName.isNotEmpty) {
if (vendorContact == null || vendorContact.isEmpty) {
report.addWarning(
'Record #$id: 벤더명은 있지만 연락처가 없습니다.',
);
}
}
}
}
/// 날짜 일관성 검증
static void _validateDateConsistency(
List<Map<String, dynamic>> data,
ValidationReport report,
) {
for (final record in data) {
final id = record['id'];
// 날짜 파싱
final startDate = _parseDate(record['start_date']);
final endDate = _parseDate(record['end_date']);
final nextDate = _parseDate(record['next_date']);
// 시작일과 종료일 검증
if (startDate != null && endDate != null) {
if (endDate.isBefore(startDate)) {
report.addError(
'Record #$id: 종료일이 시작일보다 이전입니다.',
);
}
// 기간 검증
final period = record['period_months'] as int?;
if (period != null) {
final expectedEnd = DateTime(
startDate.year,
startDate.month + period,
startDate.day,
);
// 1개월 이상 차이나면 경고
final diff = endDate.difference(expectedEnd).inDays.abs();
if (diff > 30) {
report.addWarning(
'Record #$id: 종료일이 예상 날짜와 $diff일 차이납니다.',
);
}
}
}
// 다음 유지보수 날짜 검증
if (nextDate != null && endDate != null) {
if (nextDate.isAfter(endDate)) {
report.addWarning(
'Record #$id: 다음 유지보수 날짜가 종료일 이후입니다.',
);
}
}
// 상태와 날짜 일관성
final status = record['status'] as String?;
if (status != null && endDate != null) {
final now = DateTime.now();
if (status == 'overdue' && endDate.isAfter(now)) {
report.addWarning(
'Record #$id: 상태는 overdue지만 아직 만료되지 않았습니다.',
);
}
if (status == 'upcoming' && endDate.isBefore(now)) {
report.addWarning(
'Record #$id: 상태는 upcoming이지만 이미 만료되었습니다.',
);
}
}
}
}
/// 원본 데이터와 비교
static void _compareWithOriginal(
List<Map<String, dynamic>> maintenanceData,
Map<String, dynamic> originalData,
ValidationReport report,
) {
final originalList = originalData['data'] as List<dynamic>?;
if (originalList == null) return;
// 데이터 개수 비교
if (maintenanceData.length != originalList.length) {
report.addWarning(
'데이터 개수 불일치: 원본 ${originalList.length}개, 변환 ${maintenanceData.length}',
);
}
// ID 매핑 확인
final maintenanceIds = maintenanceData.map((m) => m['id']).toSet();
final originalIds = originalList.map((l) => l['id']).toSet();
final missingIds = originalIds.difference(maintenanceIds);
if (missingIds.isNotEmpty) {
report.addError(
'누락된 레코드 ID: ${missingIds.join(', ')}',
);
}
final extraIds = maintenanceIds.difference(originalIds);
if (extraIds.isNotEmpty) {
report.addWarning(
'추가된 레코드 ID: ${extraIds.join(', ')}',
);
}
}
/// 통계 생성
static void _generateStatistics(
List<Map<String, dynamic>> data,
ValidationReport report,
) {
// 상태별 통계
final statusCount = <String, int>{};
for (final record in data) {
final status = record['status'] as String? ?? 'unknown';
statusCount[status] = (statusCount[status] ?? 0) + 1;
}
report.statistics['statusCount'] = statusCount;
// 유형별 통계
final typeCount = <String, int>{};
for (final record in data) {
final type = record['maintenance_type'] as String? ?? 'unknown';
typeCount[type] = (typeCount[type] ?? 0) + 1;
}
report.statistics['typeCount'] = typeCount;
// 비용 통계
double totalCost = 0;
int recordsWithCost = 0;
for (final record in data) {
final cost = record['cost'];
if (cost != null && cost is num) {
totalCost += cost.toDouble();
recordsWithCost++;
}
}
report.statistics['totalCost'] = totalCost;
report.statistics['averageCost'] =
recordsWithCost > 0 ? totalCost / recordsWithCost : 0;
// 기간 통계
int totalPeriod = 0;
int recordsWithPeriod = 0;
for (final record in data) {
final period = record['period_months'] as int?;
if (period != null) {
totalPeriod += period;
recordsWithPeriod++;
}
}
report.statistics['averagePeriod'] =
recordsWithPeriod > 0 ? totalPeriod / recordsWithPeriod : 0;
}
/// 날짜 파싱 헬퍼
static DateTime? _parseDate(dynamic date) {
if (date == null) return null;
if (date is DateTime) return date;
if (date is String) return DateTime.tryParse(date);
return null;
// 간단한 비즈니스 규칙: status 가 지정 집합에 포함
const allowed = {'scheduled', 'upcoming', 'overdue'};
bool business = maintenanceData.every((m) => allowed.contains(m['status']));
final ok = integrity && business;
return ValidationReport(
isValid: ok,
dataIntegrity: integrity,
businessRulesValid: business,
);
}
}
/// 검증 보고서
class ValidationReport {
int totalRecords = 0;
final List<ValidationError> errors = [];
final List<ValidationWarning> warnings = [];
final Map<String, dynamic> statistics = {};
// 추가된 필드들
bool dataIntegrity = true;
bool foreignKeyIntegrity = true;
bool businessRulesValid = true;
void addError(String message) {
errors.add(ValidationError(
message: message,
timestamp: DateTime.now(),
));
// 오류가 있으면 무결성 필드들을 false로 설정
dataIntegrity = false;
}
void addWarning(String message) {
warnings.add(ValidationWarning(
message: message,
timestamp: DateTime.now(),
));
}
bool get isValid => errors.isEmpty;
String generateSummary() {
final buffer = StringBuffer();
buffer.writeln('=== Maintenance 데이터 검증 보고서 ===');
buffer.writeln('생성 시간: ${DateTime.now()}');
buffer.writeln('');
buffer.writeln('## 요약');
buffer.writeln('- 총 레코드: $totalRecords개');
buffer.writeln('- 오류: ${errors.length}');
buffer.writeln('- 경고: ${warnings.length}');
buffer.writeln('- 검증 결과: ${isValid ? "통과 ✓" : "실패 ✗"}');
buffer.writeln('');
if (statistics.isNotEmpty) {
buffer.writeln('## 통계');
statistics.forEach((key, value) {
buffer.writeln('- $key: $value');
});
buffer.writeln('');
}
if (errors.isNotEmpty) {
buffer.writeln('## 오류 목록');
for (final error in errors) {
buffer.writeln('- ${error.message}');
}
buffer.writeln('');
}
if (warnings.isNotEmpty) {
buffer.writeln('## 경고 목록');
for (final warning in warnings) {
buffer.writeln('- ${warning.message}');
}
buffer.writeln('');
}
return buffer.toString();
}
}
/// 검증 오류
class ValidationError {
final String message;
final DateTime timestamp;
ValidationError({
required this.message,
required this.timestamp,
});
}
/// 검증 경고
class ValidationWarning {
final String message;
final DateTime timestamp;
ValidationWarning({
required this.message,
required this.timestamp,
});
}