feat: V/R 유지보수 시스템 전환 및 대시보드 테이블 형태 완성
- 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:
535
lib/screens/equipment/dialogs/equipment_outbound_dialog.dart
Normal file
535
lib/screens/equipment/dialogs/equipment_outbound_dialog.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user