프로젝트 최초 커밋

This commit is contained in:
JiWoong Sul
2025-07-02 17:45:44 +09:00
commit e346f83c97
235 changed files with 23139 additions and 0 deletions

View File

@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
/// 자동완성 텍스트 필드 위젯
///
/// 입력, 드롭다운, 포커스, 필터링, 선택 기능을 모두 포함한다.
class AutocompleteTextField extends StatefulWidget {
final String label;
final String value;
final List<String> items;
final bool isRequired;
final String hintText;
final void Function(String) onChanged;
final void Function(String) onSelected;
final FocusNode? focusNode;
const AutocompleteTextField({
Key? key,
required this.label,
required this.value,
required this.items,
required this.onChanged,
required this.onSelected,
this.isRequired = false,
this.hintText = '',
this.focusNode,
}) : super(key: key);
@override
State<AutocompleteTextField> createState() => _AutocompleteTextFieldState();
}
class _AutocompleteTextFieldState extends State<AutocompleteTextField> {
late final TextEditingController _controller;
late final FocusNode _focusNode;
late List<String> _filteredItems;
bool _showDropdown = false;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.value);
_focusNode = widget.focusNode ?? FocusNode();
_filteredItems = List.from(widget.items);
_controller.addListener(_onTextChanged);
_focusNode.addListener(() {
setState(() {
if (_focusNode.hasFocus) {
_showDropdown = _filteredItems.isNotEmpty;
} else {
_showDropdown = false;
}
});
});
}
@override
void didUpdateWidget(covariant AutocompleteTextField oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value != _controller.text) {
_controller.text = widget.value;
}
if (widget.items != oldWidget.items) {
_filteredItems = List.from(widget.items);
}
}
@override
void dispose() {
if (widget.focusNode == null) {
_focusNode.dispose();
}
_controller.dispose();
super.dispose();
}
// 입력값 변경 시 필터링
void _onTextChanged() {
final text = _controller.text;
setState(() {
if (text.isEmpty) {
_filteredItems = List.from(widget.items);
} else {
_filteredItems =
widget.items
.where(
(item) => item.toLowerCase().contains(text.toLowerCase()),
)
.toList();
// 시작 부분이 일치하는 항목 우선 정렬
_filteredItems.sort((a, b) {
bool aStartsWith = a.toLowerCase().startsWith(text.toLowerCase());
bool bStartsWith = b.toLowerCase().startsWith(text.toLowerCase());
if (aStartsWith && !bStartsWith) return -1;
if (!aStartsWith && bStartsWith) return 1;
return a.compareTo(b);
});
}
_showDropdown = _filteredItems.isNotEmpty && _focusNode.hasFocus;
widget.onChanged(text);
});
}
void _handleSelect(String value) {
setState(() {
_controller.text = value;
_showDropdown = false;
});
widget.onSelected(value);
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
TextFormField(
controller: _controller,
focusNode: _focusNode,
decoration: InputDecoration(
labelText: widget.label,
hintText: widget.hintText,
),
validator: (value) {
if (widget.isRequired && (value == null || value.isEmpty)) {
return '${widget.label}을(를) 입력해주세요';
}
return null;
},
onSaved: (value) {
widget.onSelected(value ?? '');
},
),
if (_showDropdown)
Positioned(
top: 50,
left: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
constraints: const BoxConstraints(maxHeight: 200),
child: ListView.builder(
shrinkWrap: true,
itemCount: _filteredItems.length,
itemBuilder: (context, index) {
return InkWell(
onTap: () => _handleSelect(_filteredItems[index]),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Text(_filteredItems[index]),
),
);
},
),
),
),
],
);
}
}

View File

@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
// 출고 정보(회사, 담당자, 라이센스 등)를 아이콘과 함께 표시하는 위젯
class EquipmentOutInfoIcon extends StatelessWidget {
final String infoType; // company, manager, license 등
final String text;
const EquipmentOutInfoIcon({
super.key,
required this.infoType,
required this.text,
});
@override
Widget build(BuildContext context) {
// infoType에 따라 아이콘 결정
IconData iconData;
switch (infoType) {
case 'company':
iconData = Icons.business;
break;
case 'manager':
iconData = Icons.person;
break;
case 'license':
iconData = Icons.book;
break;
default:
iconData = Icons.info;
}
// 아이콘과 텍스트를 Row로 표시
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(iconData, size: 14, color: Colors.grey[700]),
const SizedBox(width: 4),
Flexible(
child: Text(
text,
style: TextStyle(fontSize: 13, color: Colors.grey[800]),
overflow: TextOverflow.ellipsis,
),
),
],
);
}
}

View File

@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:superport/utils/constants.dart';
// 장비 상태에 따라 칩(Chip) 위젯을 반환하는 함수형 위젯
class EquipmentStatusChip extends StatelessWidget {
final String status;
const EquipmentStatusChip({super.key, required this.status});
@override
Widget build(BuildContext context) {
// 상태별 칩 색상 및 텍스트 지정
Color backgroundColor;
String statusText;
switch (status) {
case EquipmentStatus.in_:
backgroundColor = Colors.green;
statusText = '입고';
break;
case EquipmentStatus.out:
backgroundColor = Colors.orange;
statusText = '출고';
break;
case EquipmentStatus.rent:
backgroundColor = Colors.blue;
statusText = '대여';
break;
case EquipmentStatus.repair:
backgroundColor = Colors.blue;
statusText = '수리중';
break;
case EquipmentStatus.damaged:
backgroundColor = Colors.red;
statusText = '손상';
break;
case EquipmentStatus.lost:
backgroundColor = Colors.purple;
statusText = '분실';
break;
case EquipmentStatus.etc:
backgroundColor = Colors.grey;
statusText = '기타';
break;
default:
backgroundColor = Colors.grey;
statusText = '알 수 없음';
}
// 칩 위젯 반환
return Chip(
label: Text(
statusText,
style: const TextStyle(color: Colors.white, fontSize: 12),
),
backgroundColor: backgroundColor,
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 5),
);
}
}

View File

@@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/screens/equipment/widgets/equipment_summary_row.dart';
// 다중 선택 장비 요약 카드
class EquipmentMultiSummaryCard extends StatelessWidget {
final List<Map<String, dynamic>> selectedEquipments;
const EquipmentMultiSummaryCard({
super.key,
required this.selectedEquipments,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
'선택된 장비 목록 (${selectedEquipments.length}개)',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
...selectedEquipments.map((equipmentData) {
final equipment = equipmentData['equipment'] as Equipment;
return EquipmentSingleSummaryCard(equipment: equipment);
}).toList(),
],
);
}
}
// 단일 장비 요약 카드
class EquipmentSingleSummaryCard extends StatelessWidget {
final Equipment equipment;
const EquipmentSingleSummaryCard({super.key, required this.equipment});
// 날짜 포맷 유틸리티
String _formatDate(DateTime? date) {
if (date == null) return '정보 없음';
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
return Card(
elevation: 3,
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Row(
children: [
Icon(
Icons.inventory,
color: Theme.of(context).primaryColor,
),
const SizedBox(width: 8),
Expanded(
child: Text(
equipment.name.isNotEmpty ? equipment.name : '이름 없음',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue.shade300),
),
child: Text(
'수량: ${equipment.quantity}',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: Colors.blue.shade800,
),
),
),
],
),
const Divider(thickness: 1.5),
EquipmentSummaryRow(
label: '제조사',
value:
equipment.manufacturer.isNotEmpty
? equipment.manufacturer
: '정보 없음',
),
EquipmentSummaryRow(
label: '카테고리',
value:
equipment.category.isNotEmpty
? '${equipment.category} > ${equipment.subCategory} > ${equipment.subSubCategory}'
: '정보 없음',
),
EquipmentSummaryRow(
label: '시리얼 번호',
value:
(equipment.serialNumber != null &&
equipment.serialNumber!.isNotEmpty)
? equipment.serialNumber!
: '정보 없음',
),
EquipmentSummaryRow(
label: '출고 수량',
value: equipment.quantity.toString(),
),
EquipmentSummaryRow(
label: '입고일',
value: _formatDate(equipment.inDate),
),
// 워런티 정보 추가
if (equipment.warrantyLicense != null &&
equipment.warrantyLicense!.isNotEmpty)
EquipmentSummaryRow(
label: '워런티 라이센스',
value: equipment.warrantyLicense!,
),
EquipmentSummaryRow(
label: '워런티 시작일',
value: _formatDate(equipment.warrantyStartDate),
),
EquipmentSummaryRow(
label: '워런티 종료일',
value: _formatDate(equipment.warrantyEndDate),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
// 장비 요약 정보 행 위젯 (SRP, 재사용성)
class EquipmentSummaryRow extends StatelessWidget {
final String label;
final String value;
const EquipmentSummaryRow({
super.key,
required this.label,
required this.value,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 110,
child: Text(
'$label:',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
),
Expanded(
child: Text(
value,
style: TextStyle(
fontSize: 15,
color: value == '정보 없음' ? Colors.grey.shade600 : Colors.black,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/screens/equipment/widgets/equipment_status_chip.dart';
import 'package:superport/screens/equipment/widgets/equipment_out_info.dart';
import 'package:superport/utils/equipment_display_helper.dart';
// 장비 목록 테이블 위젯 (SRP, 재사용성 강화)
class EquipmentTable extends StatelessWidget {
final List<UnifiedEquipment> equipments;
final Set<String> selectedEquipmentIds;
final bool showDetailedColumns;
final void Function(int? id, String status, bool? isSelected)
onEquipmentSelected;
final String Function(int equipmentId, String infoType) getOutEquipmentInfo;
final Widget Function(UnifiedEquipment equipment) buildCategoryWithTooltip;
final void Function(int id, String status) onEdit;
final void Function(int id, String status) onDelete;
final int Function() getSelectedInStockCount;
const EquipmentTable({
super.key,
required this.equipments,
required this.selectedEquipmentIds,
required this.showDetailedColumns,
required this.onEquipmentSelected,
required this.getOutEquipmentInfo,
required this.buildCategoryWithTooltip,
required this.onEdit,
required this.onDelete,
required this.getSelectedInStockCount,
});
// 출고 정보(간소화 모드) 위젯
Widget _buildCompactOutInfo(int equipmentId) {
final company = getOutEquipmentInfo(equipmentId, 'company');
final manager = getOutEquipmentInfo(equipmentId, 'manager');
final license = getOutEquipmentInfo(equipmentId, 'license');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
EquipmentOutInfoIcon(infoType: 'company', text: company),
const SizedBox(height: 2),
EquipmentOutInfoIcon(infoType: 'manager', text: manager),
const SizedBox(height: 2),
EquipmentOutInfoIcon(infoType: 'license', text: license),
],
);
}
// 카테고리 툴팁 위젯 (UI만 담당, 축약 표기 적용)
Widget _buildCategoryWithTooltip(UnifiedEquipment equipment) {
// 한글 라벨로 표기
final fullCategory =
'대분류: ${equipment.equipment.category} / 중분류: ${equipment.equipment.subCategory} / 소분류: ${equipment.equipment.subSubCategory}';
final shortCategory = [
_shortenCategory(equipment.equipment.category),
_shortenCategory(equipment.equipment.subCategory),
_shortenCategory(equipment.equipment.subSubCategory),
].join(' > ');
return Tooltip(message: fullCategory, child: Text(shortCategory));
}
// 카테고리 축약 표기 함수 (예: 컴...)
String _shortenCategory(String category) {
if (category.length <= 2) return category;
return category.substring(0, 2) + '...';
}
@override
Widget build(BuildContext context) {
return DataTable(
headingRowHeight: 48,
dataRowMinHeight: 48,
dataRowMaxHeight: 60,
columnSpacing: 10,
horizontalMargin: 16,
columns: [
const DataColumn(label: SizedBox(width: 32, child: Text('선택'))),
const DataColumn(label: SizedBox(width: 32, child: Text('번호'))),
if (showDetailedColumns)
const DataColumn(label: SizedBox(width: 60, child: Text('제조사'))),
const DataColumn(label: SizedBox(width: 90, child: Text('장비명'))),
if (showDetailedColumns)
const DataColumn(label: SizedBox(width: 110, child: Text('분류'))),
if (showDetailedColumns)
const DataColumn(label: SizedBox(width: 60, child: Text('장비 유형'))),
if (showDetailedColumns)
const DataColumn(label: SizedBox(width: 70, child: Text('시리얼번호'))),
const DataColumn(label: SizedBox(width: 38, child: Text('수량'))),
const DataColumn(label: SizedBox(width: 80, child: Text('변경 일자'))),
const DataColumn(label: SizedBox(width: 44, child: Text('상태'))),
if (showDetailedColumns) ...[
const DataColumn(label: SizedBox(width: 90, child: Text('출고 회사'))),
const DataColumn(label: SizedBox(width: 60, child: Text('담당자'))),
const DataColumn(label: SizedBox(width: 60, child: Text('라이센스'))),
] else
const DataColumn(label: SizedBox(width: 110, child: Text('출고 정보'))),
const DataColumn(label: SizedBox(width: 60, child: Text('관리'))),
],
rows:
equipments.asMap().entries.map((entry) {
final index = entry.key;
final equipment = entry.value;
final bool isInStock = equipment.status == 'I';
final bool isOutStock = equipment.status == 'O';
return DataRow(
color: MaterialStateProperty.resolveWith<Color?>(
(Set<MaterialState> states) =>
index % 2 == 0 ? Colors.grey[50] : null,
),
cells: [
DataCell(
Checkbox(
value: selectedEquipmentIds.contains(
'${equipment.id}:${equipment.status}',
),
onChanged:
(isSelected) => onEquipmentSelected(
equipment.id,
equipment.status,
isSelected,
),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
DataCell(Text('${index + 1}')),
if (showDetailedColumns)
DataCell(
Text(
EquipmentDisplayHelper.formatManufacturer(
equipment.equipment.manufacturer,
),
),
),
DataCell(
Text(
EquipmentDisplayHelper.formatEquipmentName(
equipment.equipment.name,
),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
if (showDetailedColumns)
DataCell(buildCategoryWithTooltip(equipment)),
if (showDetailedColumns)
DataCell(
Text(
equipment.status == 'I' &&
equipment is UnifiedEquipment &&
equipment.type != null
? equipment.type!
: '-',
),
),
if (showDetailedColumns)
DataCell(
Text(
EquipmentDisplayHelper.formatSerialNumber(
equipment.equipment.serialNumber,
),
),
),
DataCell(
Text(
'${equipment.equipment.quantity}',
textAlign: TextAlign.center,
),
),
DataCell(
Text(EquipmentDisplayHelper.formatDate(equipment.date)),
),
DataCell(EquipmentStatusChip(status: equipment.status)),
if (showDetailedColumns) ...[
DataCell(
Text(
isOutStock
? getOutEquipmentInfo(equipment.id!, 'company')
: '-',
),
),
DataCell(
Text(
isOutStock
? getOutEquipmentInfo(equipment.id!, 'manager')
: '-',
),
),
DataCell(
Text(
isOutStock
? getOutEquipmentInfo(equipment.id!, 'license')
: '-',
),
),
] else
DataCell(
isOutStock
? _buildCompactOutInfo(equipment.id!)
: const Text('-'),
),
DataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(
Icons.edit,
color: Colors.blue,
size: 20,
),
constraints: const BoxConstraints(),
padding: const EdgeInsets.all(5),
onPressed:
() => onEdit(equipment.id!, equipment.status),
),
IconButton(
icon: const Icon(
Icons.delete,
color: Colors.red,
size: 20,
),
constraints: const BoxConstraints(),
padding: const EdgeInsets.all(5),
onPressed:
() => onDelete(equipment.id!, equipment.status),
),
],
),
),
],
);
}).toList(),
);
}
}