Files
superport/lib/screens/equipment/dialogs/equipment_outbound_dialog.dart
JiWoong Sul 519e1883a3
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
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>
2025-09-05 14:33:20 +09:00

535 lines
26 KiB
Dart

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