- 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>
535 lines
26 KiB
Dart
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),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
} |