프로젝트 최초 커밋

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,83 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/sidebar/sidebar_screen.dart';
import 'package:superport/screens/overview/overview_screen.dart';
import 'package:superport/screens/equipment/equipment_list.dart';
import 'package:superport/screens/company/company_list.dart';
import 'package:superport/screens/user/user_list.dart';
import 'package:superport/screens/license/license_list.dart';
import 'package:superport/screens/warehouse_location/warehouse_location_list.dart';
import 'package:superport/screens/goods/goods_list.dart';
import 'package:superport/utils/constants.dart';
/// SPA 스타일의 앱 레이아웃 클래스
/// 사이드바는 고정되고 내용만 변경되는 구조를 제공
class AppLayout extends StatefulWidget {
final String initialRoute;
const AppLayout({Key? key, this.initialRoute = Routes.home})
: super(key: key);
@override
_AppLayoutState createState() => _AppLayoutState();
}
class _AppLayoutState extends State<AppLayout> {
late String _currentRoute;
@override
void initState() {
super.initState();
_currentRoute = widget.initialRoute;
}
/// 현재 경로에 따라 적절한 컨텐츠 섹션을 반환
Widget _getContentForRoute(String route) {
switch (route) {
case Routes.home:
return const OverviewScreen();
case Routes.equipment:
case Routes.equipmentInList:
case Routes.equipmentOutList:
case Routes.equipmentRentList:
// 장비 목록 화면에 현재 라우트 정보를 전달
return EquipmentListScreen(currentRoute: route);
case Routes.goods:
return const GoodsListScreen();
case Routes.company:
return const CompanyListScreen();
case Routes.license:
return const MaintenanceListScreen();
case Routes.warehouseLocation:
return const WarehouseLocationListScreen();
default:
return const OverviewScreen();
}
}
/// 경로 변경 메서드
void _navigateTo(String route) {
setState(() {
_currentRoute = route;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Row(
children: [
// 왼쪽 사이드바
SizedBox(
width: 280,
child: SidebarMenu(
currentRoute: _currentRoute,
onRouteChanged: _navigateTo,
),
),
// 오른쪽 컨텐츠 영역
Expanded(child: _getContentForRoute(_currentRoute)),
],
),
);
}
}

View File

@@ -0,0 +1,8 @@
export 'custom_widgets/page_title.dart';
export 'custom_widgets/data_table_card.dart';
export 'custom_widgets/form_field_wrapper.dart';
export 'custom_widgets/date_picker_field.dart';
export 'custom_widgets/highlight_text.dart';
export 'custom_widgets/autocomplete_dropdown.dart';
export 'custom_widgets/category_selection_field.dart';
export 'custom_widgets/category_data.dart';

View File

@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'highlight_text.dart';
// 자동완성 드롭다운 공통 위젯
class AutocompleteDropdown extends StatelessWidget {
// 드롭다운에 표시할 항목 리스트
final List<String> items;
// 현재 입력된 텍스트(하이라이트 기준)
final String inputText;
// 항목 선택 시 콜백
final void Function(String) onSelect;
// 드롭다운 표시 여부
final bool showDropdown;
// 최대 높이(항목 개수에 따라 자동 조절)
final double maxHeight;
// 드롭다운이 비었을 때 표시할 위젯
final Widget emptyWidget;
const AutocompleteDropdown({
Key? key,
required this.items,
required this.inputText,
required this.onSelect,
required this.showDropdown,
this.maxHeight = 200,
this.emptyWidget = const Padding(
padding: EdgeInsets.all(12.0),
child: Text('검색 결과가 없습니다'),
),
}) : super(key: key);
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
height:
showDropdown
? (items.length > 4 ? maxHeight : items.length * 50.0)
: 0,
margin: EdgeInsets.only(top: showDropdown ? 4 : 0),
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: GestureDetector(
onTap: () {}, // 이벤트 버블링 방지
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.grey.shade300),
boxShadow: [
BoxShadow(
color: Colors.grey.withAlpha(77),
spreadRadius: 1,
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
child:
items.isEmpty
? emptyWidget
: ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: items.length,
separatorBuilder:
(context, index) =>
Divider(height: 1, color: Colors.grey.shade200),
itemBuilder: (context, index) {
final String item = items[index];
return ListTile(
dense: true,
title: HighlightText(
text: item,
highlight: inputText,
highlightColor: Theme.of(context).primaryColor,
),
onTap: () => onSelect(item),
);
},
),
),
),
),
);
}
}

View File

@@ -0,0 +1,18 @@
// 카테고리 데이터 (예시)
final Map<String, Map<String, List<String>>> categoryData = {
'컴퓨터': {
'데스크탑': ['사무용', '게이밍', '워크스테이션'],
'노트북': ['사무용', '게이밍', '울트라북'],
'태블릿': ['안드로이드', 'iOS', '윈도우'],
},
'네트워크': {
'라우터': ['가정용', '기업용', '산업용'],
'스위치': ['관리형', '비관리형'],
'액세스 포인트': ['실내용', '실외용'],
},
'주변기기': {
'모니터': ['LCD', 'LED', 'OLED'],
'키보드': ['유선', '무선', '기계식'],
'마우스': ['유선', '무선', '트랙볼'],
},
};

View File

@@ -0,0 +1,562 @@
import 'package:flutter/material.dart';
import 'autocomplete_dropdown.dart';
import 'form_field_wrapper.dart';
import 'category_data.dart';
// 카테고리 선택 필드 (대분류/중분류/소분류)
class CategorySelectionField extends StatefulWidget {
final String category;
final String subCategory;
final String subSubCategory;
final Function(String, String, String) onCategoryChanged;
final bool isRequired;
const CategorySelectionField({
Key? key,
required this.category,
required this.subCategory,
required this.subSubCategory,
required this.onCategoryChanged,
this.isRequired = false,
}) : super(key: key);
@override
State<CategorySelectionField> createState() => _CategorySelectionFieldState();
}
class _CategorySelectionFieldState extends State<CategorySelectionField> {
// 검색 관련 컨트롤러 및 상태 변수
final TextEditingController _categoryController = TextEditingController();
final FocusNode _categoryFocusNode = FocusNode();
bool _showCategoryDropdown = false;
List<String> _filteredCategories = [];
// 중분류 관련 변수
final TextEditingController _subCategoryController = TextEditingController();
final FocusNode _subCategoryFocusNode = FocusNode();
bool _showSubCategoryDropdown = false;
List<String> _filteredSubCategories = [];
// 소분류 관련 변수
final TextEditingController _subSubCategoryController =
TextEditingController();
final FocusNode _subSubCategoryFocusNode = FocusNode();
bool _showSubSubCategoryDropdown = false;
List<String> _filteredSubSubCategories = [];
List<String> _allCategories = [];
String _selectedCategory = '';
String _selectedSubCategory = '';
String _selectedSubSubCategory = '';
@override
void initState() {
super.initState();
_selectedCategory = widget.category;
_selectedSubCategory = widget.subCategory;
_selectedSubSubCategory = widget.subSubCategory;
_categoryController.text = _selectedCategory;
_subCategoryController.text = _selectedSubCategory;
_subSubCategoryController.text = _selectedSubSubCategory;
// 모든 카테고리 목록 초기화
_allCategories = categoryData.keys.toList();
_filteredCategories = List.from(_allCategories);
// 중분류 목록 초기화
_updateSubCategories();
// 소분류 목록 초기화
_updateSubSubCategories();
// 대분류 컨트롤러 리스너 설정
_categoryController.addListener(_onCategoryTextChanged);
_categoryFocusNode.addListener(() {
setState(() {
if (_categoryFocusNode.hasFocus) {
_showCategoryDropdown = _filteredCategories.isNotEmpty;
} else {
_showCategoryDropdown = false;
}
});
});
// 중분류 컨트롤러 리스너 설정
_subCategoryController.addListener(_onSubCategoryTextChanged);
_subCategoryFocusNode.addListener(() {
setState(() {
if (_subCategoryFocusNode.hasFocus) {
_showSubCategoryDropdown = _filteredSubCategories.isNotEmpty;
} else {
_showSubCategoryDropdown = false;
}
});
});
// 소분류 컨트롤러 리스너 설정
_subSubCategoryController.addListener(_onSubSubCategoryTextChanged);
_subSubCategoryFocusNode.addListener(() {
setState(() {
if (_subSubCategoryFocusNode.hasFocus) {
_showSubSubCategoryDropdown = _filteredSubSubCategories.isNotEmpty;
} else {
_showSubSubCategoryDropdown = false;
}
});
});
}
@override
void dispose() {
_categoryController.dispose();
_categoryFocusNode.dispose();
_subCategoryController.dispose();
_subCategoryFocusNode.dispose();
_subSubCategoryController.dispose();
_subSubCategoryFocusNode.dispose();
super.dispose();
}
// 중분류 목록 업데이트
void _updateSubCategories() {
if (_selectedCategory.isNotEmpty) {
final subCategories =
categoryData[_selectedCategory]?.keys.toList() ?? [];
_filteredSubCategories = List.from(subCategories);
} else {
_filteredSubCategories = [];
}
}
// 소분류 목록 업데이트
void _updateSubSubCategories() {
if (_selectedCategory.isNotEmpty && _selectedSubCategory.isNotEmpty) {
final subSubCategories =
categoryData[_selectedCategory]?[_selectedSubCategory] ?? [];
_filteredSubSubCategories = List.from(subSubCategories);
} else {
_filteredSubSubCategories = [];
}
}
void _onCategoryTextChanged() {
final text = _categoryController.text;
setState(() {
_selectedCategory = text;
if (text.isEmpty) {
_filteredCategories = List.from(_allCategories);
} else {
_filteredCategories =
_allCategories
.where(
(item) => item.toLowerCase().contains(text.toLowerCase()),
)
.toList();
// 시작 부분이 일치하는 항목 우선 정렬
_filteredCategories.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);
});
}
_showCategoryDropdown =
_filteredCategories.isNotEmpty && _categoryFocusNode.hasFocus;
// 카테고리가 변경되면 하위 카테고리 초기화
if (_selectedCategory != widget.category) {
_selectedSubCategory = '';
_subCategoryController.text = '';
_selectedSubSubCategory = '';
_subSubCategoryController.text = '';
_updateSubCategories();
_updateSubSubCategories();
}
// 콜백 호출
widget.onCategoryChanged(
_selectedCategory,
_selectedSubCategory,
_selectedSubSubCategory,
);
});
}
// 중분류 텍스트 변경 핸들러
void _onSubCategoryTextChanged() {
final text = _subCategoryController.text;
setState(() {
_selectedSubCategory = text;
if (_selectedCategory.isNotEmpty) {
final subCategories =
categoryData[_selectedCategory]?.keys.toList() ?? [];
if (text.isEmpty) {
_filteredSubCategories = List.from(subCategories);
} else {
_filteredSubCategories =
subCategories
.where(
(item) => item.toLowerCase().contains(text.toLowerCase()),
)
.toList();
// 시작 부분이 일치하는 항목 우선 정렬
_filteredSubCategories.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);
});
}
} else {
_filteredSubCategories = [];
}
_showSubCategoryDropdown =
_filteredSubCategories.isNotEmpty && _subCategoryFocusNode.hasFocus;
// 중분류가 변경되면 소분류 초기화
if (_selectedSubCategory != widget.subCategory) {
_selectedSubSubCategory = '';
_subSubCategoryController.text = '';
_updateSubSubCategories();
}
// 콜백 호출
widget.onCategoryChanged(
_selectedCategory,
_selectedSubCategory,
_selectedSubSubCategory,
);
});
}
// 소분류 텍스트 변경 핸들러
void _onSubSubCategoryTextChanged() {
final text = _subSubCategoryController.text;
setState(() {
_selectedSubSubCategory = text;
if (_selectedCategory.isNotEmpty && _selectedSubCategory.isNotEmpty) {
final subSubCategories =
categoryData[_selectedCategory]?[_selectedSubCategory] ?? [];
if (text.isEmpty) {
_filteredSubSubCategories = List.from(subSubCategories);
} else {
_filteredSubSubCategories =
subSubCategories
.where(
(item) => item.toLowerCase().contains(text.toLowerCase()),
)
.toList();
// 시작 부분이 일치하는 항목 우선 정렬
_filteredSubSubCategories.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);
});
}
} else {
_filteredSubSubCategories = [];
}
_showSubSubCategoryDropdown =
_filteredSubSubCategories.isNotEmpty &&
_subSubCategoryFocusNode.hasFocus;
// 콜백 호출
widget.onCategoryChanged(
_selectedCategory,
_selectedSubCategory,
_selectedSubSubCategory,
);
});
}
void _selectCategory(String category) {
setState(() {
_selectedCategory = category;
_categoryController.text = category;
_showCategoryDropdown = false;
_selectedSubCategory = '';
_subCategoryController.text = '';
_selectedSubSubCategory = '';
_subSubCategoryController.text = '';
_updateSubCategories();
_updateSubSubCategories();
widget.onCategoryChanged(
_selectedCategory,
_selectedSubCategory,
_selectedSubSubCategory,
);
});
}
// 중분류 선택 핸들러
void _selectSubCategory(String subCategory) {
setState(() {
_selectedSubCategory = subCategory;
_subCategoryController.text = subCategory;
_showSubCategoryDropdown = false;
_selectedSubSubCategory = '';
_subSubCategoryController.text = '';
_updateSubSubCategories();
widget.onCategoryChanged(
_selectedCategory,
_selectedSubCategory,
_selectedSubSubCategory,
);
});
}
// 소분류 선택 핸들러
void _selectSubSubCategory(String subSubCategory) {
setState(() {
_selectedSubSubCategory = subSubCategory;
_subSubCategoryController.text = subSubCategory;
_showSubSubCategoryDropdown = false;
widget.onCategoryChanged(
_selectedCategory,
_selectedSubCategory,
_selectedSubSubCategory,
);
});
}
@override
Widget build(BuildContext context) {
return FormFieldWrapper(
label: '카테고리',
isRequired: widget.isRequired,
child: Column(
children: [
// 대분류 입력 필드 (자동완성)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: _categoryController,
focusNode: _categoryFocusNode,
decoration: InputDecoration(
hintText: '대분류',
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
suffixIcon:
_categoryController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
setState(() {
_categoryController.clear();
_selectedCategory = '';
_selectedSubCategory = '';
_selectedSubSubCategory = '';
_subCategoryController.clear();
_subSubCategoryController.clear();
_filteredCategories = List.from(_allCategories);
_filteredSubCategories = [];
_filteredSubSubCategories = [];
_showCategoryDropdown =
_categoryFocusNode.hasFocus;
widget.onCategoryChanged('', '', '');
});
},
)
: IconButton(
icon: const Icon(Icons.arrow_drop_down),
onPressed: () {
setState(() {
_showCategoryDropdown = !_showCategoryDropdown;
});
},
),
),
validator: (value) {
if (widget.isRequired && (value == null || value.isEmpty)) {
return '대분류를 선택해주세요';
}
return null;
},
onTap: () {
setState(() {
if (!_showCategoryDropdown) {
_showCategoryDropdown = true;
}
});
},
),
// 대분류 자동완성 드롭다운
AutocompleteDropdown(
items: _filteredCategories,
inputText: _categoryController.text,
onSelect: _selectCategory,
showDropdown: _showCategoryDropdown,
),
],
),
const SizedBox(height: 12),
// 중분류 및 소분류 선택 행
Row(
children: [
// 중분류 입력 필드 (자동완성)
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: _subCategoryController,
focusNode: _subCategoryFocusNode,
decoration: InputDecoration(
hintText: '중분류',
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
suffixIcon:
_subCategoryController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
setState(() {
_subCategoryController.clear();
_selectedSubCategory = '';
_selectedSubSubCategory = '';
_subSubCategoryController.clear();
_updateSubCategories();
_updateSubSubCategories();
_showSubCategoryDropdown =
_subCategoryFocusNode.hasFocus;
widget.onCategoryChanged(
_selectedCategory,
'',
'',
);
});
},
)
: IconButton(
icon: const Icon(Icons.arrow_drop_down),
onPressed: () {
setState(() {
_showSubCategoryDropdown =
!_showSubCategoryDropdown;
});
},
),
),
enabled: _selectedCategory.isNotEmpty,
onTap: () {
setState(() {
if (!_showSubCategoryDropdown &&
_filteredSubCategories.isNotEmpty) {
_showSubCategoryDropdown = true;
}
});
},
),
// 중분류 자동완성 드롭다운
AutocompleteDropdown(
items: _filteredSubCategories,
inputText: _subCategoryController.text,
onSelect: _selectSubCategory,
showDropdown: _showSubCategoryDropdown,
),
],
),
),
const SizedBox(width: 12),
// 소분류 입력 필드 (자동완성)
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: _subSubCategoryController,
focusNode: _subSubCategoryFocusNode,
decoration: InputDecoration(
hintText: '소분류',
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
suffixIcon:
_subSubCategoryController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
setState(() {
_subSubCategoryController.clear();
_selectedSubSubCategory = '';
_updateSubSubCategories();
_showSubSubCategoryDropdown =
_subSubCategoryFocusNode.hasFocus;
widget.onCategoryChanged(
_selectedCategory,
_selectedSubCategory,
'',
);
});
},
)
: IconButton(
icon: const Icon(Icons.arrow_drop_down),
onPressed: () {
setState(() {
_showSubSubCategoryDropdown =
!_showSubSubCategoryDropdown;
});
},
),
),
enabled:
_selectedCategory.isNotEmpty &&
_selectedSubCategory.isNotEmpty,
onTap: () {
setState(() {
if (!_showSubSubCategoryDropdown &&
_filteredSubSubCategories.isNotEmpty) {
_showSubSubCategoryDropdown = true;
}
});
},
),
// 소분류 자동완성 드롭다운
AutocompleteDropdown(
items: _filteredSubSubCategories,
inputText: _subSubCategoryController.text,
onSelect: _selectSubSubCategory,
showDropdown: _showSubSubCategoryDropdown,
),
],
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
// 데이터 테이블 카드
class DataTableCard extends StatelessWidget {
final Widget child;
final String? title;
final double? width;
const DataTableCard({Key? key, required this.child, this.title, this.width})
: super(key: key);
@override
Widget build(BuildContext context) {
return Container(
width: width,
decoration: AppThemeTailwind.cardDecoration,
margin: const EdgeInsets.only(bottom: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null)
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(title!, style: AppThemeTailwind.subheadingStyle),
),
Padding(padding: const EdgeInsets.all(16.0), child: child),
],
),
);
}
}

View File

@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'form_field_wrapper.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
// 날짜 선택 필드
class DatePickerField extends StatelessWidget {
final DateTime selectedDate;
final Function(DateTime) onDateChanged;
final bool allowFutureDate;
final bool isRequired;
const DatePickerField({
Key? key,
required this.selectedDate,
required this.onDateChanged,
this.allowFutureDate = false,
this.isRequired = false,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: selectedDate,
firstDate: DateTime(2000),
lastDate: allowFutureDate ? DateTime(2100) : DateTime.now(),
);
if (picked != null && picked != selectedDate) {
onDateChanged(picked);
}
},
child: FormFieldWrapper(
label: '날짜',
isRequired: isRequired,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 15),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${selectedDate.year}-${selectedDate.month.toString().padLeft(2, '0')}-${selectedDate.day.toString().padLeft(2, '0')}',
style: AppThemeTailwind.bodyStyle,
),
const Icon(Icons.calendar_today, size: 20),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
// 폼 필드 래퍼
class FormFieldWrapper extends StatelessWidget {
final String label;
final Widget child;
final bool isRequired;
const FormFieldWrapper({
Key? key,
required this.label,
required this.child,
this.isRequired = false,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
label,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
if (isRequired)
const Text(
' *',
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8.0),
child,
],
),
);
}
}

View File

@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
// 자동완성 드롭다운에서 텍스트 하이라이트를 위한 위젯
class HighlightText extends StatelessWidget {
// 전체 텍스트
final String text;
// 하이라이트할 부분
final String highlight;
// 하이라이트 색상
final Color highlightColor;
// 텍스트 스타일
final TextStyle? style;
const HighlightText({
Key? key,
required this.text,
required this.highlight,
this.highlightColor = Colors.blue,
this.style,
}) : super(key: key);
@override
Widget build(BuildContext context) {
if (highlight.isEmpty) {
// 하이라이트가 없으면 전체 텍스트 반환
return Text(text, style: style);
}
final String lowerText = text.toLowerCase();
final String lowerHighlight = highlight.toLowerCase();
final int start = lowerText.indexOf(lowerHighlight);
if (start < 0) {
// 일치하는 부분이 없으면 전체 텍스트 반환
return Text(text, style: style);
}
final int end = start + highlight.length;
return RichText(
text: TextSpan(
style: style ?? DefaultTextStyle.of(context).style,
children: [
if (start > 0) TextSpan(text: text.substring(0, start)),
TextSpan(
text: text.substring(start, end),
style: TextStyle(
fontWeight: FontWeight.bold,
color: highlightColor,
),
),
if (end < text.length) TextSpan(text: text.substring(end)),
],
),
);
}
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
// 페이지 타이틀 위젯
class PageTitle extends StatelessWidget {
final String title;
final Widget? rightWidget;
final double? width;
const PageTitle({Key? key, required this.title, this.rightWidget, this.width})
: super(key: key);
@override
Widget build(BuildContext context) {
return Container(
width: width,
margin: const EdgeInsets.only(bottom: 16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(title, style: AppThemeTailwind.headingStyle),
if (rightWidget != null) rightWidget!,
],
),
);
}
}

View File

@@ -0,0 +1,9 @@
/// 메트로닉 스타일 공통 레이아웃 컴포넌트 barrel 파일
/// 각 위젯은 SRP에 따라 별도 파일로 분리되어 있습니다.
export 'metronic_page_container.dart';
export 'metronic_card.dart';
export 'metronic_stats_card.dart';
export 'metronic_page_title.dart';
export 'metronic_data_table.dart';
export 'metronic_form_field.dart';
export 'metronic_tab_container.dart';

View File

@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
class MainLayout extends StatelessWidget {
final String title;
final Widget child;
final String currentRoute;
final List<Widget>? actions;
final bool showBackButton;
final Widget? floatingActionButton;
const MainLayout({
Key? key,
required this.title,
required this.child,
required this.currentRoute,
this.actions,
this.showBackButton = false,
this.floatingActionButton,
}) : super(key: key);
@override
Widget build(BuildContext context) {
// MetronicCloud 스타일: 상단부 플랫, 여백 넓게, 타이틀/경로/버튼 스타일링
return Scaffold(
backgroundColor: AppThemeTailwind.surface,
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 상단 앱바
_buildAppBar(context),
// 컨텐츠
Expanded(child: child),
],
),
floatingActionButton: floatingActionButton,
);
}
Widget _buildAppBar(BuildContext context) {
// 상단 앱바: 경로 텍스트가 수직 중앙에 오도록 조정, 배경색/글자색 변경
return Container(
height: 88,
padding: const EdgeInsets.symmetric(horizontal: 40),
decoration: BoxDecoration(
color: AppThemeTailwind.surface, // 회색 배경
border: const Border(
bottom: BorderSide(color: Color(0xFFF3F6F9), width: 1),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center, // Row 내에서 수직 중앙 정렬
children: [
// 경로 및 타이틀 영역 (수직 중앙 정렬)
Column(
mainAxisAlignment: MainAxisAlignment.center, // Column 내에서 수직 중앙 정렬
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 경로 텍스트 (폰트 사이즈 2배, 검은색 글자)
Text(
_getBreadcrumb(currentRoute),
style: TextStyle(
fontSize: 26,
color: AppThemeTailwind.dark,
), // 검은색 글자
),
// 타이틀이 있을 때만 표시
if (title.isNotEmpty)
Text(
title,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppThemeTailwind.dark,
),
),
],
),
const Spacer(),
if (actions != null)
Row(
children:
actions!
.map(
(w) => Padding(
padding: const EdgeInsets.only(left: 8),
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: AppThemeTailwind.muted,
width: 1,
),
color: const Color(0xFFF7F8FA),
borderRadius: BorderRadius.circular(8),
),
child: w,
),
),
)
.toList(),
),
],
),
);
}
// 현재 라우트에 따라 경로 문자열을 반환하는 함수
String _getBreadcrumb(String route) {
// 실제 라우트에 따라 경로를 한글로 변환 (예시)
switch (route) {
case '/':
case '/home':
return '홈 / 대시보드';
case '/equipment':
return '홈 / 장비 관리';
case '/company':
return '홈 / 회사 관리';
case '/maintenance':
return '홈 / 유지보수 관리';
case '/item':
return '홈 / 물품 관리';
default:
return '';
}
}
}

View File

@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
/// 메트로닉 스타일 카드 위젯 (SRP 분리)
class MetronicCard extends StatelessWidget {
final String? title;
final Widget child;
final List<Widget>? actions;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin;
const MetronicCard({
Key? key,
this.title,
required this.child,
this.actions,
this.padding,
this.margin,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
decoration: AppThemeTailwind.cardDecoration,
margin: margin,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null || actions != null)
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (title != null)
Text(title!, style: AppThemeTailwind.subheadingStyle),
if (actions != null) Row(children: actions!),
],
),
),
Padding(padding: padding ?? const EdgeInsets.all(16), child: child),
],
),
);
}
}

View File

@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
import 'package:superport/screens/common/metronic_card.dart';
/// 메트로닉 스타일 데이터 테이블 카드 위젯 (SRP 분리)
class MetronicDataTable extends StatelessWidget {
final List<DataColumn> columns;
final List<DataRow> rows;
final String? title;
final bool isLoading;
final String? emptyMessage;
const MetronicDataTable({
Key? key,
required this.columns,
required this.rows,
this.title,
this.isLoading = false,
this.emptyMessage,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return MetronicCard(
title: title,
child:
isLoading
? const Center(child: CircularProgressIndicator())
: rows.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Text(
emptyMessage ?? '데이터가 없습니다.',
style: AppThemeTailwind.bodyStyle,
),
),
)
: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: DataTable(
columns: columns,
rows: rows,
headingRowColor: MaterialStateProperty.all(
AppThemeTailwind.light,
),
dataRowMaxHeight: 60,
columnSpacing: 24,
horizontalMargin: 16,
),
),
),
);
}
}

View File

@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
/// 메트로닉 스타일 폼 필드 래퍼 위젯 (SRP 분리)
class MetronicFormField extends StatelessWidget {
final String label;
final Widget child;
final bool isRequired;
final String? helperText;
const MetronicFormField({
Key? key,
required this.label,
required this.child,
this.isRequired = false,
this.helperText,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
label,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
color: AppThemeTailwind.dark,
),
),
if (isRequired)
const Text(
' *',
style: TextStyle(
color: AppThemeTailwind.danger,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
child,
if (helperText != null)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(helperText!, style: AppThemeTailwind.smallText),
),
],
),
);
}
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
/// 메트로닉 스타일 페이지 컨테이너 위젯 (SRP 분리)
class MetronicPageContainer extends StatelessWidget {
final String title;
final Widget child;
final List<Widget>? actions;
final bool showBackButton;
const MetronicPageContainer({
Key? key,
required this.title,
required this.child,
this.actions,
this.showBackButton = true,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(title),
automaticallyImplyLeading: showBackButton,
actions: actions,
elevation: 0,
),
body: Container(
color: AppThemeTailwind.surface,
padding: const EdgeInsets.all(16),
child: child,
),
);
}
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
/// 메트로닉 스타일 페이지 타이틀 위젯 (SRP 분리)
class MetronicPageTitle extends StatelessWidget {
final String title;
final VoidCallback? onAddPressed;
final String? addButtonLabel;
const MetronicPageTitle({
Key? key,
required this.title,
this.onAddPressed,
this.addButtonLabel,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(title, style: AppThemeTailwind.headingStyle),
if (onAddPressed != null)
ElevatedButton.icon(
onPressed: onAddPressed,
icon: const Icon(Icons.add),
label: Text(addButtonLabel ?? '추가'),
style: AppThemeTailwind.primaryButtonStyle,
),
],
),
);
}
}

View File

@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
/// 메트로닉 스타일 통계 카드 위젯 (SRP 분리)
class MetronicStatsCard extends StatelessWidget {
final String title;
final String value;
final String? subtitle;
final IconData? icon;
final Color? iconBackgroundColor;
final bool showTrend;
final double? trendPercentage;
final bool isPositiveTrend;
const MetronicStatsCard({
Key? key,
required this.title,
required this.value,
this.subtitle,
this.icon,
this.iconBackgroundColor,
this.showTrend = false,
this.trendPercentage,
this.isPositiveTrend = true,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
decoration: AppThemeTailwind.cardDecoration,
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: AppThemeTailwind.bodyStyle.copyWith(
color: AppThemeTailwind.muted,
fontSize: 12,
),
),
if (icon != null)
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: iconBackgroundColor ?? AppThemeTailwind.light,
shape: BoxShape.circle,
),
child: Icon(
icon,
color:
iconBackgroundColor != null
? Colors.white
: AppThemeTailwind.primary,
size: 16,
),
),
],
),
const SizedBox(height: 8),
Text(
value,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppThemeTailwind.dark,
),
),
if (subtitle != null || showTrend) const SizedBox(height: 4),
if (subtitle != null)
Text(subtitle!, style: AppThemeTailwind.smallText),
if (showTrend && trendPercentage != null)
Row(
children: [
Icon(
isPositiveTrend ? Icons.arrow_upward : Icons.arrow_downward,
color:
isPositiveTrend
? AppThemeTailwind.success
: AppThemeTailwind.danger,
size: 12,
),
const SizedBox(width: 4),
Text(
'${trendPercentage!.toStringAsFixed(1)}%',
style: TextStyle(
color:
isPositiveTrend
? AppThemeTailwind.success
: AppThemeTailwind.danger,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
/// 메트로닉 스타일 탭 컨테이너 위젯 (SRP 분리)
class MetronicTabContainer extends StatelessWidget {
final List<String> tabs;
final List<Widget> tabViews;
final int initialIndex;
const MetronicTabContainer({
Key? key,
required this.tabs,
required this.tabViews,
this.initialIndex = 0,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: tabs.length,
initialIndex: initialIndex,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: Color(0xFFE5E7EB), width: 1),
),
),
child: TabBar(
tabs: tabs.map((tab) => Tab(text: tab)).toList(),
labelColor: AppThemeTailwind.primary,
unselectedLabelColor: AppThemeTailwind.muted,
labelStyle: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
),
indicatorColor: AppThemeTailwind.primary,
indicatorWeight: 2,
),
),
Expanded(child: TabBarView(children: tabViews)),
],
),
);
}
}

View File

@@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
/// Metronic Admin 테일윈드 테마 (데모6 스타일)
class AppThemeTailwind {
// 메인 컬러 팔레트
static const Color primary = Color(0xFF5867DD);
static const Color secondary = Color(0xFF34BFA3);
static const Color success = Color(0xFF1BC5BD);
static const Color info = Color(0xFF8950FC);
static const Color warning = Color(0xFFFFA800);
static const Color danger = Color(0xFFF64E60);
static const Color light = Color(0xFFF3F6F9);
static const Color dark = Color(0xFF181C32);
static const Color muted = Color(0xFFB5B5C3);
// 배경 컬러
static const Color surface = Color(0xFFF7F8FA);
static const Color cardBackground = Colors.white;
// 테마 데이터
static ThemeData get lightTheme {
return ThemeData(
primaryColor: primary,
colorScheme: const ColorScheme.light(
primary: primary,
secondary: secondary,
background: surface,
surface: cardBackground,
error: danger,
),
scaffoldBackgroundColor: surface,
fontFamily: 'Poppins',
// AppBar 테마
appBarTheme: const AppBarTheme(
backgroundColor: Colors.white,
foregroundColor: dark,
elevation: 0,
centerTitle: false,
titleTextStyle: TextStyle(
color: dark,
fontSize: 18,
fontWeight: FontWeight.w600,
),
iconTheme: IconThemeData(color: dark),
),
// 버튼 테마
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
),
),
// 카드 테마
cardTheme: CardTheme(
color: Colors.white,
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
margin: const EdgeInsets.symmetric(vertical: 8),
),
// 입력 폼 테마
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: Color(0xFFE5E7EB)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: Color(0xFFE5E7EB)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: primary),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: danger),
),
floatingLabelBehavior: FloatingLabelBehavior.never,
),
// 데이터 테이블 테마
dataTableTheme: const DataTableThemeData(
headingRowColor: WidgetStatePropertyAll(light),
dividerThickness: 1,
columnSpacing: 24,
headingTextStyle: TextStyle(
color: dark,
fontWeight: FontWeight.w600,
fontSize: 14,
),
dataTextStyle: TextStyle(color: Color(0xFF6C7293), fontSize: 14),
),
);
}
// 스타일 - 헤딩 및 텍스트
static const TextStyle headingStyle = TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: dark,
);
static const TextStyle subheadingStyle = TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: dark,
);
static const TextStyle bodyStyle = TextStyle(
fontSize: 14,
color: Color(0xFF6C7293),
);
// 굵은 본문 텍스트
static const TextStyle bodyBoldStyle = TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: dark,
);
static const TextStyle smallText = TextStyle(fontSize: 12, color: muted);
// 버튼 스타일
static final ButtonStyle primaryButtonStyle = ElevatedButton.styleFrom(
backgroundColor: primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
);
// 라벨 스타일
static const TextStyle formLabelStyle = TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: dark,
);
static final ButtonStyle secondaryButtonStyle = ElevatedButton.styleFrom(
backgroundColor: secondary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
);
static final ButtonStyle outlineButtonStyle = OutlinedButton.styleFrom(
foregroundColor: primary,
side: const BorderSide(color: primary),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
);
// 카드 장식
static final BoxDecoration cardDecoration = BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(13),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
);
// 기타 장식
static final BoxDecoration containerDecoration = BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE5E7EB)),
);
static const EdgeInsets cardPadding = EdgeInsets.all(20);
static const EdgeInsets listPadding = EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
);
}

View File

@@ -0,0 +1,278 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/common/custom_widgets.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
import 'package:superport/utils/address_constants.dart';
import 'package:superport/models/address_model.dart';
/// 주소 입력 컴포넌트
///
/// 우편번호, 시/도 드롭다운, 상세주소로 구성된 주소 입력 폼입니다.
/// 1행 3열 구조로 배치되어 있으며, 각 필드는 SRP 원칙에 따라 개별적으로 관리됩니다.
class AddressInput extends StatefulWidget {
/// 최초 우편번호 값
final String initialZipCode;
/// 최초 시/도 값
final String initialRegion;
/// 최초 상세 주소 값
final String initialDetailAddress;
/// 주소가 변경될 때 호출되는 콜백 함수
/// zipCode, region, detailAddress를 매개변수로 전달합니다.
final Function(String zipCode, String region, String detailAddress)
onAddressChanged;
/// 필수 입력 여부
final bool isRequired;
const AddressInput({
Key? key,
this.initialZipCode = '',
this.initialRegion = '',
this.initialDetailAddress = '',
required this.onAddressChanged,
this.isRequired = false,
}) : super(key: key);
@override
State<AddressInput> createState() => _AddressInputState();
/// Address 객체를 받아 읽기 전용으로 표시하는 위젯
static Widget readonly({required Address address}) {
// 회사 리스트와 동일하게 address.toString() 사용, 스타일도 bodyStyle로 통일
return Text(address.toString(), style: AppThemeTailwind.bodyStyle);
}
}
class _AddressInputState extends State<AddressInput> {
// 텍스트 컨트롤러
late TextEditingController _zipCodeController;
late TextEditingController _detailAddressController;
// 드롭다운 관련 변수
String _selectedRegion = '';
bool _showRegionDropdown = false;
// 레이어 링크 (드롭다운 위치 조정용)
final LayerLink _regionLayerLink = LayerLink();
// 오버레이 엔트리 (드롭다운 메뉴)
OverlayEntry? _regionOverlayEntry;
// 포커스 노드
final FocusNode _regionFocusNode = FocusNode();
@override
void initState() {
super.initState();
_zipCodeController = TextEditingController(text: widget.initialZipCode);
_selectedRegion = widget.initialRegion;
_detailAddressController = TextEditingController(
text: widget.initialDetailAddress,
);
// 컨트롤러 변경 리스너 등록
_zipCodeController.addListener(_notifyAddressChanged);
_detailAddressController.addListener(_notifyAddressChanged);
}
@override
void dispose() {
_zipCodeController.dispose();
_detailAddressController.dispose();
_removeRegionOverlay();
_regionFocusNode.dispose();
super.dispose();
}
/// 주소 변경을 상위 위젯에 알립니다.
void _notifyAddressChanged() {
widget.onAddressChanged(
_zipCodeController.text,
_selectedRegion,
_detailAddressController.text,
);
}
/// 시/도 드롭다운을 토글합니다.
void _toggleRegionDropdown() {
setState(() {
if (_showRegionDropdown) {
_removeRegionOverlay();
} else {
_showRegionDropdown = true;
_showRegionOverlay();
}
});
}
/// 시/도 드롭다운 오버레이를 제거합니다.
void _removeRegionOverlay() {
_regionOverlayEntry?.remove();
_regionOverlayEntry = null;
_showRegionDropdown = false;
}
/// 시/도 드롭다운 오버레이를 표시합니다.
void _showRegionOverlay() {
final RenderBox renderBox = context.findRenderObject() as RenderBox;
final size = renderBox.size;
final offset = renderBox.localToGlobal(Offset.zero);
final availableHeight =
MediaQuery.of(context).size.height - offset.dy - 100;
final maxHeight = 300.0 < availableHeight ? 300.0 : availableHeight;
_regionOverlayEntry = OverlayEntry(
builder:
(context) => Positioned(
width: 200,
child: CompositedTransformFollower(
link: _regionLayerLink,
showWhenUnlinked: false,
offset: const Offset(0, 45),
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(4),
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),
spreadRadius: 1,
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
constraints: BoxConstraints(maxHeight: maxHeight),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
...KoreanRegions.topLevel.map(
(region) => InkWell(
onTap: () {
setState(() {
_selectedRegion = region;
_removeRegionOverlay();
_notifyAddressChanged();
});
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
width: double.infinity,
height: 48,
child: Text(
region,
style: AppThemeTailwind.bodyStyle.copyWith(
fontSize: 16,
),
),
),
),
),
],
),
),
),
),
),
),
);
Overlay.of(context).insert(_regionOverlayEntry!);
}
@override
Widget build(BuildContext context) {
return FormFieldWrapper(
label: '주소',
isRequired: widget.isRequired,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 우편번호 입력 필드 (1열)
Expanded(
flex: 2,
child: TextField(
controller: _zipCodeController,
decoration: InputDecoration(
hintText: AddressLabels.zipCodeHint,
labelText: AddressLabels.zipCode,
border: const OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
// 시/도 선택 드롭다운 (2열)
Expanded(
flex: 3,
child: CompositedTransformTarget(
link: _regionLayerLink,
child: InkWell(
onTap: _toggleRegionDropdown,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 0,
),
height: 48,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_selectedRegion.isEmpty
? AddressLabels.regionHint
: _selectedRegion,
style: TextStyle(
fontSize: 16,
color:
_selectedRegion.isEmpty
? Colors.grey.shade600
: Colors.black,
),
),
const Icon(Icons.arrow_drop_down),
],
),
),
),
),
),
const SizedBox(width: 8),
// 상세 주소 입력 필드 (3열)
Expanded(
flex: 7,
child: TextField(
controller: _detailAddressController,
decoration: InputDecoration(
hintText: AddressLabels.detailHint,
labelText: AddressLabels.detail,
border: const OutlineInputBorder(),
),
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,255 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart'; // kDebugMode 사용
/// 드롭다운 + 자동완성 + 텍스트 입력을 모두 지원하는 공통 위젯
///
/// - 텍스트 입력 시 자동완성 추천 리스트 노출
/// - 드롭다운 버튼 클릭 시 전체 리스트 노출
/// - 직접 입력, 선택 모두 가능
/// - 재사용성 및 SRP 준수
class AutocompleteDropdownField 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 bool enabled;
const AutocompleteDropdownField({
Key? key,
required this.label,
required this.value,
required this.items,
required this.onChanged,
required this.onSelected,
this.isRequired = false,
this.hintText = '',
this.enabled = true,
}) : super(key: key);
@override
State<AutocompleteDropdownField> createState() =>
_AutocompleteDropdownFieldState();
}
class _AutocompleteDropdownFieldState extends State<AutocompleteDropdownField> {
late TextEditingController _controller;
late final FocusNode _focusNode;
late List<String> _filteredItems;
bool _showDropdown = false;
// 위젯 고유 키 추가 (동적 값 기반 키 대신 고정된 ValueKey 사용)
final GlobalKey _fieldKey = GlobalKey();
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.value);
if (kDebugMode) {
print(
'[AutocompleteDropdownField:initState] label=${widget.label}, value=${widget.value}',
);
}
_focusNode = FocusNode();
_filteredItems = List.from(widget.items);
_controller.addListener(_onTextChanged);
_focusNode.addListener(_handleFocusChange);
}
@override
void didUpdateWidget(covariant AutocompleteDropdownField oldWidget) {
super.didUpdateWidget(oldWidget);
// 항상 부모의 value와 내부 컨트롤러를 동기화 (동기화 누락 방지)
_controller.text = widget.value;
if (kDebugMode) {
print(
'[AutocompleteDropdownField:didUpdateWidget] label=${widget.label}, value 동기화: widget.value=${widget.value}, controller.text=${_controller.text}',
);
}
if (widget.items != oldWidget.items) {
_filteredItems = List.from(widget.items);
}
}
@override
void dispose() {
_focusNode.dispose();
_controller.dispose();
super.dispose();
}
void _handleFocusChange() {
setState(() {
// 포커스가 있고 필터링된 아이템이 있을 때만 드롭다운 표시
_showDropdown = _focusNode.hasFocus && _filteredItems.isNotEmpty;
// 포커스가 없으면 드롭다운 닫기
if (!_focusNode.hasFocus) {
_showDropdown = false;
}
});
if (kDebugMode) {
print(
'[AutocompleteDropdownField:_handleFocusChange] label=${widget.label}, hasFocus=${_focusNode.hasFocus}, showDropdown=$_showDropdown',
);
}
}
void _onTextChanged() {
final text = _controller.text;
if (kDebugMode) {
print(
'[AutocompleteDropdownField:_onTextChanged] label=${widget.label}, text=$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) {
if (kDebugMode) {
print(
'[AutocompleteDropdownField:_handleSelect] 선택값=$value, 이전 값=${_controller.text}',
);
}
// 1. 값 전달 (부모 콜백)
widget.onChanged(value); // 입력값 변경 콜백
widget.onSelected(value); // 선택 콜백
// 2. 부모 setState 이후, 프레임이 끝난 뒤 드롭다운 닫기 (즉각 반영 보장)
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_controller.text = value;
_controller.selection = TextSelection.collapsed(offset: value.length);
_showDropdown = false;
});
_focusNode.unfocus();
});
if (kDebugMode) {
print(
'[AutocompleteDropdownField:_handleSelect] 업데이트 완료: controller.text=${_controller.text}',
);
}
}
void _toggleDropdown() {
setState(() {
_showDropdown = !_showDropdown && _filteredItems.isNotEmpty;
if (_showDropdown) {
_focusNode.requestFocus();
}
});
}
@override
Widget build(BuildContext context) {
return Stack(
key: _fieldKey, // 고정된 키 사용
children: [
TextFormField(
controller: _controller,
focusNode: _focusNode,
enabled: widget.enabled,
decoration: InputDecoration(
labelText: widget.label,
hintText: widget.hintText,
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_controller.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear),
onPressed:
widget.enabled
? () {
setState(() {
_controller.clear();
_filteredItems = List.from(widget.items);
_showDropdown =
_focusNode.hasFocus &&
_filteredItems.isNotEmpty;
widget.onSelected('');
});
}
: null,
),
IconButton(
icon: const Icon(Icons.arrow_drop_down),
onPressed: widget.enabled ? _toggleDropdown : null,
),
],
),
),
validator: (value) {
if (widget.isRequired && (value == null || value.isEmpty)) {
return '${widget.label}을(를) 입력해주세요';
}
return null;
},
onSaved: (value) {
widget.onSelected(value ?? '');
},
),
if (_showDropdown)
Positioned(
left: 0,
right: 0,
top: 56, // TextFormField 높이만큼 아래로
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(4),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200),
child: ListView.builder(
shrinkWrap: true,
itemCount: _filteredItems.length,
itemBuilder: (context, index) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (kDebugMode) {
print(
'[AutocompleteDropdownField:GestureDetector:onTap] label=${widget.label}, 선택값=${_filteredItems[index]}',
);
}
_handleSelect(_filteredItems[index]);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Text(_filteredItems[index]),
),
);
},
),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import '../custom_widgets.dart'; // AutocompleteDropdown, HighlightText 등 사용
// 입력 필드 + 자동완성 드롭다운을 하나로 묶은 공통 위젯
class CategoryAutocompleteField extends StatefulWidget {
// 입력 필드의 힌트 텍스트
final String hintText;
// 현재 선택된 값
final String value;
// 항목 리스트
final List<String> items;
// 필수 입력 여부
final bool isRequired;
// 선택 시 콜백
final void Function(String) onSelect;
// 입력값 변경 시 콜백(옵션)
final void Function(String)? onChanged;
// 비활성화 여부
final bool enabled;
const CategoryAutocompleteField({
Key? key,
required this.hintText,
required this.value,
required this.items,
required this.onSelect,
this.isRequired = false,
this.onChanged,
this.enabled = true,
}) : super(key: key);
@override
State<CategoryAutocompleteField> createState() =>
_CategoryAutocompleteFieldState();
}
class _CategoryAutocompleteFieldState extends State<CategoryAutocompleteField> {
// 텍스트 입력 컨트롤러
late final TextEditingController _controller;
// 포커스 노드
final FocusNode _focusNode = FocusNode();
// 드롭다운 표시 여부
bool _showDropdown = false;
// 필터링된 항목 리스트
List<String> _filteredItems = [];
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.value);
_filteredItems = List.from(widget.items);
_controller.addListener(_onTextChanged);
_focusNode.addListener(() {
setState(() {
if (_focusNode.hasFocus) {
_showDropdown = _filteredItems.isNotEmpty;
} else {
_showDropdown = false;
}
});
});
}
@override
void didUpdateWidget(covariant CategoryAutocompleteField 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() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
// 입력값 변경 시 필터링
void _onTextChanged() {
final String 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;
if (widget.onChanged != null) {
widget.onChanged!(text);
}
});
}
// 항목 선택 시 처리
void _handleSelect(String value) {
setState(() {
_controller.text = value;
_showDropdown = false;
});
widget.onSelect(value);
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: _controller,
focusNode: _focusNode,
decoration: InputDecoration(
hintText: widget.hintText,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
suffixIcon:
_controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed:
widget.enabled
? () {
setState(() {
_controller.clear();
_filteredItems = List.from(widget.items);
_showDropdown = _focusNode.hasFocus;
widget.onSelect('');
});
}
: null,
)
: IconButton(
icon: const Icon(Icons.arrow_drop_down),
onPressed:
widget.enabled
? () {
setState(() {
_showDropdown = !_showDropdown;
});
}
: null,
),
),
enabled: widget.enabled,
validator: (value) {
if (widget.isRequired && (value == null || value.isEmpty)) {
return '${widget.hintText}를 선택해주세요';
}
return null;
},
onTap: () {
setState(() {
if (!_showDropdown) {
_showDropdown = true;
}
});
},
),
AutocompleteDropdown(
items: _filteredItems,
inputText: _controller.text,
onSelect: _handleSelect,
showDropdown: _showDropdown,
),
],
);
}
}

View File

@@ -0,0 +1,76 @@
// 회사/지점 드롭다운 공통 위젯
// 여러 도메인에서 재사용 가능
import 'package:flutter/material.dart';
import '../../../models/company_model.dart';
class CompanyBranchDropdown extends StatelessWidget {
final List<Company> companies;
final int? selectedCompanyId;
final int? selectedBranchId;
final List<Branch> branches;
final void Function(int? companyId) onCompanyChanged;
final void Function(int? branchId) onBranchChanged;
const CompanyBranchDropdown({
super.key,
required this.companies,
required this.selectedCompanyId,
required this.selectedBranchId,
required this.branches,
required this.onCompanyChanged,
required this.onBranchChanged,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 회사 드롭다운
DropdownButtonFormField<int>(
value: selectedCompanyId,
decoration: const InputDecoration(hintText: '소속 회사를 선택하세요'),
items:
companies
.map(
(company) => DropdownMenuItem<int>(
value: company.id,
child: Text(company.name),
),
)
.toList(),
onChanged: onCompanyChanged,
validator: (value) {
if (value == null) {
return '소속 회사를 선택해주세요';
}
return null;
},
),
const SizedBox(height: 12),
// 지점 드롭다운 (지점이 있을 때만)
if (branches.isNotEmpty)
DropdownButtonFormField<int>(
value: selectedBranchId,
decoration: const InputDecoration(hintText: '소속 지점을 선택하세요'),
items:
branches
.map(
(branch) => DropdownMenuItem<int>(
value: branch.id,
child: Text(branch.name),
),
)
.toList(),
onChanged: onBranchChanged,
validator: (value) {
if (branches.isNotEmpty && value == null) {
return '소속 지점을 선택해주세요';
}
return null;
},
),
],
);
}
}

View File

@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
/// 페이지네이션 위젯 (<< < 1 2 3 ... 10 > >>)
/// - totalCount: 전체 아이템 수
/// - currentPage: 현재 페이지 (1부터 시작)
/// - pageSize: 페이지당 아이템 수
/// - onPageChanged: 페이지 변경 콜백
class Pagination extends StatelessWidget {
final int totalCount;
final int currentPage;
final int pageSize;
final ValueChanged<int> onPageChanged;
const Pagination({
Key? key,
required this.totalCount,
required this.currentPage,
required this.pageSize,
required this.onPageChanged,
}) : super(key: key);
@override
Widget build(BuildContext context) {
// 전체 페이지 수 계산
final int totalPages = (totalCount / pageSize).ceil();
// 페이지네이션 버튼 최대 10개
final int maxButtons = 10;
// 시작 페이지 계산
int startPage = ((currentPage - 1) ~/ maxButtons) * maxButtons + 1;
int endPage = (startPage + maxButtons - 1).clamp(1, totalPages);
List<Widget> pageButtons = [];
for (int i = startPage; i <= endPage; i++) {
pageButtons.add(
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
minimumSize: const Size(36, 36),
backgroundColor: i == currentPage ? Colors.blue : Colors.white,
foregroundColor: i == currentPage ? Colors.white : Colors.black,
padding: EdgeInsets.zero,
),
onPressed: i == currentPage ? null : () => onPageChanged(i),
child: Text('$i'),
),
),
);
}
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 가장 처음 페이지로 이동
IconButton(
icon: const Icon(Icons.first_page),
tooltip: '처음',
onPressed: currentPage > 1 ? () => onPageChanged(1) : null,
),
// 이전 페이지로 이동
IconButton(
icon: const Icon(Icons.chevron_left),
tooltip: '이전',
onPressed:
currentPage > 1 ? () => onPageChanged(currentPage - 1) : null,
),
// 페이지 번호 버튼들
...pageButtons,
// 다음 페이지로 이동
IconButton(
icon: const Icon(Icons.chevron_right),
tooltip: '다음',
onPressed:
currentPage < totalPages
? () => onPageChanged(currentPage + 1)
: null,
),
// 마지막 페이지로 이동
IconButton(
icon: const Icon(Icons.last_page),
tooltip: '마지막',
onPressed:
currentPage < totalPages ? () => onPageChanged(totalPages) : null,
),
],
);
}
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
/// 공통 비고 입력 위젯
/// 여러 화면에서 재사용할 수 있도록 설계
class RemarkInput extends StatelessWidget {
final TextEditingController controller;
final String label;
final String hint;
final FormFieldValidator<String>? validator;
final int minLines;
final int? maxLines;
final bool enabled;
const RemarkInput({
Key? key,
required this.controller,
this.label = '비고',
this.hint = '비고를 입력하세요',
this.validator,
this.minLines = 4,
this.maxLines,
this.enabled = true,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
minLines: minLines,
maxLines: maxLines,
enabled: enabled,
validator: validator,
decoration: InputDecoration(
labelText: label,
hintText: hint,
border: const OutlineInputBorder(),
),
);
}
}

View File

@@ -0,0 +1,398 @@
/// 회사 등록 및 수정 화면
///
/// SRP(단일 책임 원칙)에 따라 컴포넌트를 분리하여 구현한 리팩토링 버전
/// - 컨트롤러: CompanyFormController - 비즈니스 로직 담당
/// - 위젯:
/// - CompanyFormHeader: 회사명 및 주소 입력
/// - ContactInfoForm: 담당자 정보 입력
/// - BranchCard: 지점 정보 카드
/// - CompanyNameAutocomplete: 회사명 자동완성
/// - MapDialog: 지도 다이얼로그
/// - DuplicateCompanyDialog: 중복 회사 확인 다이얼로그
/// - CompanyTypeSelector: 회사 유형 선택 라디오 버튼
/// - 유틸리티:
/// - PhoneUtils: 전화번호 관련 유틸리티
import 'package:flutter/material.dart';
import 'package:superport/models/address_model.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/screens/common/custom_widgets.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
import 'package:superport/screens/company/controllers/company_form_controller.dart';
import 'package:superport/screens/company/widgets/branch_card.dart';
import 'package:superport/screens/company/widgets/company_form_header.dart';
import 'package:superport/screens/company/widgets/contact_info_form.dart';
import 'package:superport/screens/company/widgets/duplicate_company_dialog.dart';
import 'package:superport/screens/company/widgets/map_dialog.dart';
import 'package:superport/screens/company/widgets/branch_form_widget.dart';
import 'package:superport/services/mock_data_service.dart';
import 'dart:async';
import 'dart:math' as math;
import 'package:superport/screens/company/controllers/branch_form_controller.dart';
/// 회사 유형 선택 위젯 (체크박스)
class CompanyTypeSelector extends StatelessWidget {
final List<CompanyType> selectedTypes;
final Function(CompanyType, bool) onTypeChanged;
const CompanyTypeSelector({
Key? key,
required this.selectedTypes,
required this.onTypeChanged,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('회사 유형', style: AppThemeTailwind.formLabelStyle),
const SizedBox(height: 8),
Row(
children: [
// 고객사 체크박스
Checkbox(
value: selectedTypes.contains(CompanyType.customer),
onChanged: (checked) {
onTypeChanged(CompanyType.customer, checked ?? false);
},
),
const Text('고객사'),
const SizedBox(width: 24),
// 파트너사 체크박스
Checkbox(
value: selectedTypes.contains(CompanyType.partner),
onChanged: (checked) {
onTypeChanged(CompanyType.partner, checked ?? false);
},
),
const Text('파트너사'),
],
),
],
),
);
}
}
class CompanyFormScreen extends StatefulWidget {
final Map? args;
const CompanyFormScreen({Key? key, this.args}) : super(key: key);
@override
_CompanyFormScreenState createState() => _CompanyFormScreenState();
}
class _CompanyFormScreenState extends State<CompanyFormScreen> {
late CompanyFormController _controller;
bool isBranch = false;
String? mainCompanyName;
int? companyId;
int? branchId;
@override
void initState() {
super.initState();
// controller는 didChangeDependencies에서 초기화
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final args = widget.args;
if (args != null) {
isBranch = args['isBranch'] ?? false;
mainCompanyName = args['mainCompanyName'];
companyId = args['companyId'];
branchId = args['branchId'];
}
_controller = CompanyFormController(
dataService: MockDataService(),
companyId: companyId,
);
// 지점 수정 모드일 때 branchId로 branch 정보 세팅
if (isBranch && branchId != null) {
final company = MockDataService().getCompanyById(companyId!);
// 디버그: 진입 시 companyId, branchId, company, branches 정보 출력
print('[DEBUG] 지점 수정 진입: companyId=$companyId, branchId=$branchId');
if (company != null && company.branches != null) {
print(
'[DEBUG] 불러온 company.name=${company.name}, branches=${company.branches!.map((b) => 'id:${b.id}, name:${b.name}, remark:${b.remark}').toList()}',
);
final branch = company.branches!.firstWhere(
(b) => b.id == branchId,
orElse: () => company.branches!.first,
);
print(
'[DEBUG] 선택된 branch: id=${branch.id}, name=${branch.name}, remark=${branch.remark}',
);
// 폼 컨트롤러의 각 필드에 branch 정보 세팅
_controller.nameController.text = branch.name;
_controller.companyAddress = branch.address;
_controller.contactNameController.text = branch.contactName ?? '';
_controller.contactPositionController.text =
branch.contactPosition ?? '';
_controller.selectedPhonePrefix = extractPhonePrefix(
branch.contactPhone ?? '',
_controller.phonePrefixes,
);
_controller
.contactPhoneController
.text = extractPhoneNumberWithoutPrefix(
branch.contactPhone ?? '',
_controller.phonePrefixes,
);
_controller.contactEmailController.text = branch.contactEmail ?? '';
// 지점 단일 입력만 허용 (branchControllers 초기화)
_controller.branchControllers.clear();
_controller.branchControllers.add(
BranchFormController(
branch: branch,
positions: _controller.positions,
phonePrefixes: _controller.phonePrefixes,
),
);
}
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
// 지점 추가 후 스크롤 처리 (branchControllers 기반)
void _scrollToAddedBranchCard() {
if (_controller.branchControllers.isEmpty ||
!_controller.scrollController.hasClients) {
return;
}
// 추가 버튼 위치까지 스크롤 - 지점 추가 버튼이 있는 위치를 계산하여 그 위치로 스크롤
final double additionalOffset = 80.0;
final maxPos = _controller.scrollController.position.maxScrollExtent;
final currentPos = _controller.scrollController.position.pixels;
final targetPos = math.min(currentPos + additionalOffset, maxPos - 20.0);
_controller.scrollController.animateTo(
targetPos,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutQuad,
);
}
// 지점 추가
void _addBranch() {
setState(() {
_controller.addBranch();
});
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 100), () {
_scrollToAddedBranchCard();
Future.delayed(const Duration(milliseconds: 300), () {
// 마지막 지점의 포커스 노드로 포커스 이동
if (_controller.branchControllers.isNotEmpty) {
_controller.branchControllers.last.focusNode.requestFocus();
}
});
});
});
}
// 회사 저장
void _saveCompany() {
final duplicateCompany = _controller.checkDuplicateCompany();
if (duplicateCompany != null) {
DuplicateCompanyDialog.show(context, duplicateCompany);
return;
}
if (_controller.saveCompany()) {
Navigator.pop(context, true);
}
}
@override
Widget build(BuildContext context) {
final isEditMode = companyId != null;
final String title =
isBranch
? '${mainCompanyName ?? ''} 지점 정보 수정'
: (isEditMode ? '회사 정보 수정' : '회사 등록');
final String nameLabel = isBranch ? '지점명' : '회사명';
final String nameHint = isBranch ? '지점명을 입력하세요' : '회사명을 입력하세요';
// 지점 수정 모드일 때는 BranchFormWidget만 단독 노출
if (isBranch && branchId != null) {
return Scaffold(
appBar: AppBar(title: Text(title)),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _controller.formKey,
child: BranchFormWidget(
controller: _controller.branchControllers[0],
index: 0,
onRemove: null,
onAddressChanged: (address) {
setState(() {
_controller.updateBranchAddress(0, address);
});
},
),
),
),
);
}
// ... 기존 본사/신규 등록 모드 렌더링
return GestureDetector(
onTap: () {
setState(() {
if (_controller.showCompanyNameDropdown) {
_controller.showCompanyNameDropdown = false;
}
});
FocusScope.of(context).unfocus();
},
child: Scaffold(
appBar: AppBar(title: Text(title)),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _controller.formKey,
child: SingleChildScrollView(
controller: _controller.scrollController,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 회사 유형 선택 (체크박스)
CompanyTypeSelector(
selectedTypes: _controller.selectedCompanyTypes,
onTypeChanged: (type, checked) {
setState(() {
_controller.toggleCompanyType(type, checked);
});
},
),
// 회사 기본 정보 헤더 (회사명/지점명 + 주소)
CompanyFormHeader(
nameController: _controller.nameController,
nameFocusNode: _controller.nameFocusNode,
companyNames: _controller.companyNames,
filteredCompanyNames: _controller.filteredCompanyNames,
showCompanyNameDropdown:
_controller.showCompanyNameDropdown,
onCompanyNameSelected: (name) {
setState(() {
_controller.selectCompanyName(name);
});
},
onShowMapPressed: () {
final fullAddress = _controller.companyAddress.toString();
MapDialog.show(context, fullAddress);
},
onNameSaved: (value) {},
onAddressChanged: (address) {
setState(() {
_controller.updateCompanyAddress(address);
});
},
initialAddress: _controller.companyAddress,
nameLabel: nameLabel,
nameHint: nameHint,
remarkController: _controller.remarkController,
),
// 담당자 정보
ContactInfoForm(
contactNameController: _controller.contactNameController,
contactPositionController:
_controller.contactPositionController,
contactPhoneController: _controller.contactPhoneController,
contactEmailController: _controller.contactEmailController,
positions: _controller.positions,
selectedPhonePrefix: _controller.selectedPhonePrefix,
phonePrefixes: _controller.phonePrefixes,
onPhonePrefixChanged: (value) {
setState(() {
_controller.selectedPhonePrefix = value;
});
},
onNameSaved: (value) {},
onPositionSaved: (value) {},
onPhoneSaved: (value) {},
onEmailSaved: (value) {},
),
// 지점 정보(하단) 및 +지점추가 버튼은 본사/신규 등록일 때만 노출
if (!(isBranch && branchId != null)) ...[
if (_controller.branchControllers.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 16.0, bottom: 8.0),
child: Text(
'지점 정보',
style: AppThemeTailwind.subheadingStyle,
),
),
if (_controller.branchControllers.isNotEmpty)
for (
int i = 0;
i < _controller.branchControllers.length;
i++
)
BranchFormWidget(
controller: _controller.branchControllers[i],
index: i,
onRemove: () {
setState(() {
_controller.removeBranch(i);
});
},
onAddressChanged: (address) {
setState(() {
_controller.updateBranchAddress(i, address);
});
},
),
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: ElevatedButton.icon(
onPressed: _addBranch,
icon: const Icon(Icons.add),
label: const Text('지점 추가'),
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
// 저장 버튼
Padding(
padding: const EdgeInsets.only(top: 24.0, bottom: 16.0),
child: ElevatedButton(
onPressed: _saveCompany,
style: ElevatedButton.styleFrom(
backgroundColor: AppThemeTailwind.primary,
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(
isEditMode ? '수정 완료' : '등록 완료',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,501 @@
import 'package:flutter/material.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
import 'package:superport/screens/common/main_layout.dart';
import 'package:superport/screens/common/custom_widgets.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/screens/common/widgets/pagination.dart';
import 'package:superport/screens/company/widgets/company_branch_dialog.dart';
class CompanyListScreen extends StatefulWidget {
const CompanyListScreen({super.key});
@override
State<CompanyListScreen> createState() => _CompanyListScreenState();
}
class _CompanyListScreenState extends State<CompanyListScreen> {
final MockDataService _dataService = MockDataService();
List<Company> _companies = [];
// 페이지네이션 상태 추가
int _currentPage = 1; // 현재 페이지 (1부터 시작)
final int _pageSize = 10; // 페이지당 개수
@override
void initState() {
super.initState();
_loadData();
}
void _loadData() {
setState(() {
_companies = _dataService.getAllCompanies();
// 데이터가 변경되면 첫 페이지로 이동
_currentPage = 1;
});
}
void _navigateToAddScreen() async {
final result = await Navigator.pushNamed(context, '/company/add');
if (result == true) {
_loadData();
}
}
void _navigateToEditScreen(int id) async {
final result = await Navigator.pushNamed(
context,
'/company/edit',
arguments: id,
);
if (result == true) {
_loadData();
}
}
void _deleteCompany(int id) {
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: const Text('삭제 확인'),
content: const Text('이 회사 정보를 삭제하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소'),
),
TextButton(
onPressed: () {
_dataService.deleteCompany(id);
Navigator.pop(context);
_loadData();
},
child: const Text('삭제'),
),
],
),
);
}
// 회사 유형에 따라 칩 위젯 생성 (복수)
Widget _buildCompanyTypeChips(List<CompanyType> types) {
return Row(
children:
types.map((type) {
final Color textColor =
type == CompanyType.customer
? Colors.blue.shade800
: Colors.green.shade800;
final String label = companyTypeToString(type);
return Container(
margin: const EdgeInsets.only(right: 4),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: textColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
label,
style: TextStyle(
color: textColor,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
);
}).toList(),
);
}
// 본사/지점 구분 표시 위젯
Widget _buildCompanyTypeLabel(bool isBranch, {String? mainCompanyName}) {
if (isBranch) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.account_tree, size: 16, color: Colors.blue.shade600),
const SizedBox(width: 4),
const Text('지점'),
],
);
} else {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.business, size: 16, color: Colors.grey.shade700),
const SizedBox(width: 4),
const Text('본사'),
],
);
}
}
// 회사 이름 표시 위젯 (지점인 경우 "본사명 > 지점명" 형식)
Widget _buildCompanyNameText(
Company company,
bool isBranch, {
String? mainCompanyName,
}) {
if (isBranch && mainCompanyName != null) {
return Text.rich(
TextSpan(
children: [
TextSpan(
text: isBranch ? '' : '',
style: TextStyle(color: Colors.grey.shade600, fontSize: 14),
),
TextSpan(
text: isBranch ? '$mainCompanyName > ' : '',
style: TextStyle(
color: Colors.grey.shade700,
fontWeight: FontWeight.normal,
),
),
TextSpan(
text: company.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
);
} else {
return Text(
company.name,
style: const TextStyle(fontWeight: FontWeight.bold),
);
}
}
// 지점(본사+지점)만 보여주는 팝업 오픈 함수
void _showBranchDialog(Company mainCompany) {
showDialog(
context: context,
builder: (context) => CompanyBranchDialog(mainCompany: mainCompany),
);
}
@override
Widget build(BuildContext context) {
// 대시보드 폭에 맞게 조정
final screenWidth = MediaQuery.of(context).size.width;
final maxContentWidth = screenWidth > 1200 ? 1200.0 : screenWidth - 32;
// 본사와 지점 구분하기 위한 데이터 준비
final List<Map<String, dynamic>> displayCompanies = [];
for (final company in _companies) {
displayCompanies.add({
'company': company,
'isBranch': false,
'mainCompanyName': null,
});
if (company.branches != null) {
for (final branch in company.branches!) {
displayCompanies.add({
'branch': branch, // 지점 객체 자체 저장
'companyId': company.id, // 본사 id 저장
'isBranch': true,
'mainCompanyName': company.name,
});
}
}
}
// 페이지네이션 데이터 슬라이싱
final int totalCount = displayCompanies.length;
final int startIndex = (_currentPage - 1) * _pageSize;
final int endIndex =
(startIndex + _pageSize) > totalCount
? totalCount
: (startIndex + _pageSize);
final List<Map<String, dynamic>> pagedCompanies = displayCompanies.sublist(
startIndex,
endIndex,
);
return MainLayout(
title: '회사 관리',
currentRoute: Routes.company,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadData,
color: Colors.grey,
),
],
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageTitle(
title: '회사 목록',
width: maxContentWidth - 32,
rightWidget: ElevatedButton.icon(
onPressed: _navigateToAddScreen,
icon: const Icon(Icons.add),
label: const Text('추가'),
style: AppThemeTailwind.primaryButtonStyle,
),
),
Expanded(
child: DataTableCard(
width: maxContentWidth - 32,
child:
pagedCompanies.isEmpty
? const Center(child: Text('등록된 회사 정보가 없습니다.'))
: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Container(
width: maxContentWidth - 32,
constraints: BoxConstraints(
minWidth: maxContentWidth - 64,
),
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: DataTable(
columns: const [
DataColumn(label: Text('번호')),
DataColumn(label: Text('구분')),
DataColumn(label: Text('회사명')),
DataColumn(label: Text('유형')),
DataColumn(label: Text('주소')),
DataColumn(label: Text('지점 수 (본사만 표시)')),
DataColumn(label: Text('관리')),
],
rows:
pagedCompanies.asMap().entries.map((entry) {
final index = entry.key;
final data = entry.value;
final bool isBranch =
data['isBranch'] as bool;
final String? mainCompanyName =
data['mainCompanyName'] as String?;
if (isBranch) {
final Branch branch =
data['branch'] as Branch;
final int companyId =
data['companyId'] as int;
return DataRow(
cells: [
DataCell(
Text('${startIndex + index + 1}'),
),
DataCell(
_buildCompanyTypeLabel(
true,
mainCompanyName:
mainCompanyName,
),
),
DataCell(
_buildCompanyNameText(
Company(
id: branch.id,
name: branch.name,
address: branch.address,
contactName:
branch.contactName,
contactPosition:
branch.contactPosition,
contactPhone:
branch.contactPhone,
contactEmail:
branch.contactEmail,
companyTypes: [],
remark: branch.remark,
),
true,
mainCompanyName:
mainCompanyName,
),
),
DataCell(
_buildCompanyTypeChips([]),
),
DataCell(
Text(branch.address.toString()),
),
DataCell(const Text('')),
DataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(
Icons.edit,
color:
AppThemeTailwind
.primary,
),
onPressed: () {
Navigator.pushNamed(
context,
'/company/edit',
arguments: {
'companyId':
companyId,
'isBranch': true,
'mainCompanyName':
mainCompanyName,
'branchId': branch.id,
},
).then((result) {
if (result == true)
_loadData();
});
},
),
IconButton(
icon: const Icon(
Icons.delete,
color:
AppThemeTailwind
.danger,
),
onPressed: () {
// 지점 삭제 로직 필요시 구현
},
),
],
),
),
],
);
} else {
final Company company =
data['company'] as Company;
return DataRow(
cells: [
DataCell(
Text('${startIndex + index + 1}'),
),
DataCell(
_buildCompanyTypeLabel(false),
),
DataCell(
_buildCompanyNameText(
company,
false,
),
),
DataCell(
_buildCompanyTypeChips(
company.companyTypes,
),
),
DataCell(
Text(company.address.toString()),
),
DataCell(
GestureDetector(
onTap: () {
if ((company
.branches
?.isNotEmpty ??
false)) {
_showBranchDialog(company);
}
},
child: MouseRegion(
cursor:
SystemMouseCursors.click,
child: Text(
'${(company.branches?.length ?? 0)}',
style: TextStyle(
color:
(company
.branches
?.isNotEmpty ??
false)
? Colors.blue
: Colors.black,
decoration:
(company
.branches
?.isNotEmpty ??
false)
? TextDecoration
.underline
: TextDecoration
.none,
),
),
),
),
),
DataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(
Icons.edit,
color:
AppThemeTailwind
.primary,
),
onPressed: () {
Navigator.pushNamed(
context,
'/company/edit',
arguments: {
'companyId':
company.id,
'isBranch': false,
},
).then((result) {
if (result == true)
_loadData();
});
},
),
IconButton(
icon: const Icon(
Icons.delete,
color:
AppThemeTailwind
.danger,
),
onPressed: () {
_deleteCompany(
company.id!,
);
},
),
],
),
),
],
);
}
}).toList(),
),
),
),
),
),
),
// 페이지네이션 위젯 추가
if (totalCount > _pageSize)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Pagination(
totalCount: totalCount,
currentPage: _currentPage,
pageSize: _pageSize,
onPageChanged: (page) {
setState(() {
_currentPage = page;
});
},
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:superport/models/address_model.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/utils/phone_utils.dart';
/// 지점(Branch) 폼 컨트롤러
///
/// 각 지점의 상태, 컨트롤러, 포커스, 드롭다운, 전화번호 등 관리를 담당
class BranchFormController {
// 지점 데이터
Branch branch;
// 입력 컨트롤러
final TextEditingController nameController;
final TextEditingController contactNameController;
final TextEditingController contactPositionController;
final TextEditingController contactPhoneController;
final TextEditingController contactEmailController;
final TextEditingController remarkController;
// 포커스 노드
final FocusNode focusNode;
// 카드 키(위젯 식별용)
final GlobalKey cardKey;
// 직책 드롭다운 상태
final ValueNotifier<bool> positionDropdownNotifier;
// 전화번호 접두사
String selectedPhonePrefix;
// 직책 목록(공통 상수로 관리 권장)
final List<String> positions;
// 전화번호 접두사 목록(공통 상수로 관리 권장)
final List<String> phonePrefixes;
BranchFormController({
required this.branch,
required this.positions,
required this.phonePrefixes,
}) : nameController = TextEditingController(text: branch.name),
contactNameController = TextEditingController(
text: branch.contactName ?? '',
),
contactPositionController = TextEditingController(
text: branch.contactPosition ?? '',
),
contactPhoneController = TextEditingController(
text: PhoneUtils.extractPhoneNumberWithoutPrefix(
branch.contactPhone ?? '',
phonePrefixes,
),
),
contactEmailController = TextEditingController(
text: branch.contactEmail ?? '',
),
remarkController = TextEditingController(text: branch.remark ?? ''),
focusNode = FocusNode(),
cardKey = GlobalKey(),
positionDropdownNotifier = ValueNotifier<bool>(false),
selectedPhonePrefix = PhoneUtils.extractPhonePrefix(
branch.contactPhone ?? '',
phonePrefixes,
);
/// 주소 업데이트
void updateAddress(Address address) {
branch = branch.copyWith(address: address);
}
/// 필드별 값 업데이트
void updateField(String fieldName, String value) {
switch (fieldName) {
case 'name':
branch = branch.copyWith(name: value);
break;
case 'contactName':
branch = branch.copyWith(contactName: value);
break;
case 'contactPosition':
branch = branch.copyWith(contactPosition: value);
break;
case 'contactPhone':
branch = branch.copyWith(
contactPhone: PhoneUtils.getFullPhoneNumber(
selectedPhonePrefix,
value,
),
);
break;
case 'contactEmail':
branch = branch.copyWith(contactEmail: value);
break;
case 'remark':
branch = branch.copyWith(remark: value);
break;
}
}
/// 전화번호 접두사 변경
void updatePhonePrefix(String prefix) {
selectedPhonePrefix = prefix;
branch = branch.copyWith(
contactPhone: PhoneUtils.getFullPhoneNumber(
prefix,
contactPhoneController.text,
),
);
}
/// 리소스 해제
void dispose() {
nameController.dispose();
contactNameController.dispose();
contactPositionController.dispose();
contactPhoneController.dispose();
contactEmailController.dispose();
remarkController.dispose();
focusNode.dispose();
positionDropdownNotifier.dispose();
// cardKey는 위젯에서 자동 관리
}
}

View File

@@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:superport/models/address_model.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/screens/common/custom_widgets.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
import 'package:superport/screens/common/widgets/address_input.dart';
import 'package:superport/screens/company/widgets/contact_info_widget.dart';
import 'package:superport/utils/validators.dart';
import 'package:superport/utils/phone_utils.dart';
class BranchCard extends StatefulWidget {
final GlobalKey cardKey;
final int index;
final Branch branch;
final TextEditingController nameController;
final TextEditingController contactNameController;
final TextEditingController contactPositionController;
final TextEditingController contactPhoneController;
final TextEditingController contactEmailController;
final FocusNode focusNode;
final List<String> positions;
final List<String> phonePrefixes;
final String selectedPhonePrefix;
final ValueChanged<String> onNameChanged;
final ValueChanged<Address> onAddressChanged;
final ValueChanged<String> onContactNameChanged;
final ValueChanged<String> onContactPositionChanged;
final ValueChanged<String> onContactPhoneChanged;
final ValueChanged<String> onContactEmailChanged;
final ValueChanged<String> onPhonePrefixChanged;
final VoidCallback onDelete;
const BranchCard({
Key? key,
required this.cardKey,
required this.index,
required this.branch,
required this.nameController,
required this.contactNameController,
required this.contactPositionController,
required this.contactPhoneController,
required this.contactEmailController,
required this.focusNode,
required this.positions,
required this.phonePrefixes,
required this.selectedPhonePrefix,
required this.onNameChanged,
required this.onAddressChanged,
required this.onContactNameChanged,
required this.onContactPositionChanged,
required this.onContactPhoneChanged,
required this.onContactEmailChanged,
required this.onPhonePrefixChanged,
required this.onDelete,
}) : super(key: key);
@override
_BranchCardState createState() => _BranchCardState();
}
class _BranchCardState extends State<BranchCard> {
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
// 화면의 빈 공간 터치 시 포커스 해제
FocusScope.of(context).unfocus();
},
child: Card(
key: widget.cardKey,
margin: const EdgeInsets.only(bottom: 16.0),
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'지점 #${widget.index + 1}',
style: AppThemeTailwind.subheadingStyle,
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: widget.onDelete,
),
],
),
const SizedBox(height: 8),
FormFieldWrapper(
label: '지점명',
isRequired: true,
child: TextFormField(
controller: widget.nameController,
focusNode: widget.focusNode,
decoration: const InputDecoration(hintText: '지점명을 입력하세요'),
onChanged: widget.onNameChanged,
validator: FormValidator.required('지점명은 필수입니다'),
),
),
AddressInput(
initialZipCode: widget.branch.address.zipCode,
initialRegion: widget.branch.address.region,
initialDetailAddress: widget.branch.address.detailAddress,
onAddressChanged: (zipCode, region, detailAddress) {
final address = Address(
zipCode: zipCode,
region: region,
detailAddress: detailAddress,
);
widget.onAddressChanged(address);
},
),
// 담당자 정보 - ContactInfoWidget 사용
ContactInfoWidget(
title: '담당자 정보',
contactNameController: widget.contactNameController,
contactPositionController: widget.contactPositionController,
contactPhoneController: widget.contactPhoneController,
contactEmailController: widget.contactEmailController,
positions: widget.positions,
selectedPhonePrefix: widget.selectedPhonePrefix,
phonePrefixes: widget.phonePrefixes,
onPhonePrefixChanged: widget.onPhonePrefixChanged,
onContactNameChanged: widget.onContactNameChanged,
onContactPositionChanged: widget.onContactPositionChanged,
onContactPhoneChanged: widget.onContactPhoneChanged,
onContactEmailChanged: widget.onContactEmailChanged,
compactMode: false, // compactMode를 false로 변경하여 한 줄로 표시
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import '../controllers/branch_form_controller.dart';
import 'package:superport/models/address_model.dart';
import 'package:superport/screens/company/widgets/contact_info_form.dart';
import 'package:superport/screens/common/widgets/address_input.dart';
import 'package:superport/screens/common/widgets/remark_input.dart';
/// 지점 입력 폼 위젯
///
/// BranchFormController를 받아서 입력 필드, 드롭다운, 포커스, 전화번호 등 UI/상태를 관리한다.
class BranchFormWidget extends StatelessWidget {
final BranchFormController controller;
final int index;
final void Function()? onRemove;
final void Function(Address)? onAddressChanged;
const BranchFormWidget({
Key? key,
required this.controller,
required this.index,
this.onRemove,
this.onAddressChanged,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Card(
key: controller.cardKey,
margin: const EdgeInsets.symmetric(vertical: 8),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: TextFormField(
controller: controller.nameController,
focusNode: controller.focusNode,
decoration: const InputDecoration(labelText: '지점명'),
onChanged: (value) => controller.updateField('name', value),
),
),
if (onRemove != null)
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: onRemove,
),
],
),
const SizedBox(height: 8),
// 주소 입력: 회사와 동일한 AddressInput 위젯 사용
AddressInput(
initialZipCode: controller.branch.address.zipCode,
initialRegion: controller.branch.address.region,
initialDetailAddress: controller.branch.address.detailAddress,
isRequired: false,
onAddressChanged: (zipCode, region, detailAddress) {
controller.updateAddress(
Address(
zipCode: zipCode,
region: region,
detailAddress: detailAddress,
),
);
if (onAddressChanged != null) {
onAddressChanged!(
Address(
zipCode: zipCode,
region: region,
detailAddress: detailAddress,
),
);
}
},
),
const SizedBox(height: 8),
// 담당자 정보 입력: ContactInfoForm 위젯으로 대체 (회사 담당자와 동일 UI)
ContactInfoForm(
contactNameController: controller.contactNameController,
contactPositionController: controller.contactPositionController,
contactPhoneController: controller.contactPhoneController,
contactEmailController: controller.contactEmailController,
positions: controller.positions,
selectedPhonePrefix: controller.selectedPhonePrefix,
phonePrefixes: controller.phonePrefixes,
onPhonePrefixChanged: (value) {
controller.updatePhonePrefix(value);
},
onNameSaved: (value) {
controller.updateField('contactName', value ?? '');
},
onPositionSaved: (value) {
controller.updateField('contactPosition', value ?? '');
},
onPhoneSaved: (value) {
controller.updateField('contactPhone', value ?? '');
},
onEmailSaved: (value) {
controller.updateField('contactEmail', value ?? '');
},
),
const SizedBox(height: 8),
// 비고 입력란
RemarkInput(controller: controller.remarkController),
],
),
),
);
}
}

View File

@@ -0,0 +1,374 @@
import 'package:flutter/material.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/screens/company/widgets/company_info_card.dart';
import 'package:pdf/widgets.dart' as pw; // PDF 생성용
import 'package:printing/printing.dart'; // PDF 프린트/미리보기용
import 'dart:typed_data'; // Uint8List
import 'package:pdf/pdf.dart'; // PdfColors, PageFormat 등 전체 임포트
import 'package:superport/screens/common/custom_widgets.dart'; // DataTableCard 사용을 위한 import
import 'package:flutter/services.dart'; // rootBundle 사용을 위한 import
/// 본사와 지점 리스트를 보여주는 다이얼로그 위젯
class CompanyBranchDialog extends StatelessWidget {
final Company mainCompany;
const CompanyBranchDialog({super.key, required this.mainCompany});
// 본사+지점 정보를 PDF로 생성하는 함수
Future<Uint8List> _buildPdf(final pw.Document pdf) async {
// 한글 폰트 로드 (lib/assets/fonts/NotoSansKR-VariableFont_wght.ttf)
final fontData = await rootBundle.load(
'lib/assets/fonts/NotoSansKR-VariableFont_wght.ttf',
);
final ttf = pw.Font.ttf(fontData);
final List<Branch> branchList = mainCompany.branches ?? [];
pdf.addPage(
pw.Page(
build: (pw.Context context) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
'본사 및 지점 목록',
style: pw.TextStyle(
font: ttf, // 한글 폰트 적용
fontSize: 20,
fontWeight: pw.FontWeight.bold,
),
),
pw.SizedBox(height: 16),
pw.Table(
border: pw.TableBorder.all(color: PdfColors.grey800),
defaultVerticalAlignment: pw.TableCellVerticalAlignment.middle,
children: [
pw.TableRow(
decoration: pw.BoxDecoration(color: PdfColors.grey300),
children: [
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text('구분', style: pw.TextStyle(font: ttf)),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text('이름', style: pw.TextStyle(font: ttf)),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text('우편번호', style: pw.TextStyle(font: ttf)),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text('담당자', style: pw.TextStyle(font: ttf)),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text('직책', style: pw.TextStyle(font: ttf)),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text('전화번호', style: pw.TextStyle(font: ttf)),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text('이메일', style: pw.TextStyle(font: ttf)),
),
],
),
// 본사
pw.TableRow(
children: [
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text('본사', style: pw.TextStyle(font: ttf)),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text(
mainCompany.name,
style: pw.TextStyle(font: ttf),
),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text(
mainCompany.address.zipCode,
style: pw.TextStyle(font: ttf),
),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text(
mainCompany.contactName ?? '',
style: pw.TextStyle(font: ttf),
),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text(
mainCompany.contactPosition ?? '',
style: pw.TextStyle(font: ttf),
),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text(
mainCompany.contactPhone ?? '',
style: pw.TextStyle(font: ttf),
),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text(
mainCompany.contactEmail ?? '',
style: pw.TextStyle(font: ttf),
),
),
],
),
// 지점
...branchList.map(
(branch) => pw.TableRow(
children: [
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text('지점', style: pw.TextStyle(font: ttf)),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text(
branch.name,
style: pw.TextStyle(font: ttf),
),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text(
branch.address.zipCode,
style: pw.TextStyle(font: ttf),
),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text(
branch.contactName ?? '',
style: pw.TextStyle(font: ttf),
),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text(
branch.contactPosition ?? '',
style: pw.TextStyle(font: ttf),
),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text(
branch.contactPhone ?? '',
style: pw.TextStyle(font: ttf),
),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text(
branch.contactEmail ?? '',
style: pw.TextStyle(font: ttf),
),
),
],
),
),
],
),
],
);
},
),
);
return pdf.save();
}
// 프린트 버튼 클릭 시 PDF 미리보기 및 인쇄
void _printPopupData() async {
final pdf = pw.Document();
await Printing.layoutPdf(
onLayout: (format) async {
return _buildPdf(pdf);
},
);
}
@override
Widget build(BuildContext context) {
final List<Branch> branchList = mainCompany.branches ?? [];
// 본사와 지점 정보를 한 리스트로 합침
final List<Map<String, dynamic>> displayList = [
{
'type': '본사',
'name': mainCompany.name,
'companyTypes': mainCompany.companyTypes,
'address': mainCompany.address,
'contactName': mainCompany.contactName,
'contactPosition': mainCompany.contactPosition,
'contactPhone': mainCompany.contactPhone,
'contactEmail': mainCompany.contactEmail,
},
...branchList.map(
(branch) => {
'type': '지점',
'name': branch.name,
'companyTypes': mainCompany.companyTypes,
'address': branch.address,
'contactName': branch.contactName,
'contactPosition': branch.contactPosition,
'contactPhone': branch.contactPhone,
'contactEmail': branch.contactEmail,
},
),
];
final double maxDialogHeight = MediaQuery.of(context).size.height * 0.7;
final double maxDialogWidth = MediaQuery.of(context).size.width * 0.8;
return Dialog(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: maxDialogHeight,
maxWidth: maxDialogWidth,
),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'본사 및 지점 목록',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
Row(
children: [
IconButton(
icon: const Icon(Icons.print),
tooltip: '프린트',
onPressed: _printPopupData,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
],
),
const SizedBox(height: 16),
Expanded(
child: DataTableCard(
width: maxDialogWidth - 48,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Container(
width: maxDialogWidth - 48,
constraints: BoxConstraints(minWidth: 900),
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: DataTable(
columns: const [
DataColumn(label: Text('번호')),
DataColumn(label: Text('구분')),
DataColumn(label: Text('회사명')),
DataColumn(label: Text('유형')),
DataColumn(label: Text('주소')),
DataColumn(label: Text('담당자')),
DataColumn(label: Text('직책')),
DataColumn(label: Text('전화번호')),
DataColumn(label: Text('이메일')),
],
rows:
displayList.asMap().entries.map((entry) {
final int index = entry.key;
final data = entry.value;
return DataRow(
cells: [
DataCell(Text('${index + 1}')),
DataCell(Text(data['type'])),
DataCell(Text(data['name'])),
DataCell(
Row(
children:
(data['companyTypes']
as List<CompanyType>)
.map(
(type) => Container(
margin:
const EdgeInsets.only(
right: 4,
),
padding:
const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color:
type ==
CompanyType
.customer
? Colors
.blue
.shade50
: Colors
.green
.shade50,
borderRadius:
BorderRadius.circular(
8,
),
),
child: Text(
companyTypeToString(type),
style: TextStyle(
color:
type ==
CompanyType
.customer
? Colors
.blue
.shade800
: Colors
.green
.shade800,
fontWeight:
FontWeight.bold,
fontSize: 14,
),
),
),
)
.toList(),
),
),
DataCell(Text(data['address'].toString())),
DataCell(Text(data['contactName'] ?? '')),
DataCell(
Text(data['contactPosition'] ?? ''),
),
DataCell(Text(data['contactPhone'] ?? '')),
DataCell(Text(data['contactEmail'] ?? '')),
],
);
}).toList(),
),
),
),
),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:superport/models/address_model.dart';
import 'package:superport/screens/common/custom_widgets.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
import 'package:superport/screens/common/widgets/address_input.dart';
import 'package:superport/utils/validators.dart';
import 'package:superport/screens/company/widgets/company_name_autocomplete.dart';
import 'package:superport/screens/common/widgets/remark_input.dart';
class CompanyFormHeader extends StatelessWidget {
final TextEditingController nameController;
final FocusNode nameFocusNode;
final List<String> companyNames;
final List<String> filteredCompanyNames;
final bool showCompanyNameDropdown;
final Function(String) onCompanyNameSelected;
final Function() onShowMapPressed;
final ValueChanged<String?> onNameSaved;
final ValueChanged<Address> onAddressChanged;
final Address initialAddress;
final String nameLabel;
final String nameHint;
final TextEditingController remarkController;
const CompanyFormHeader({
Key? key,
required this.nameController,
required this.nameFocusNode,
required this.companyNames,
required this.filteredCompanyNames,
required this.showCompanyNameDropdown,
required this.onCompanyNameSelected,
required this.onShowMapPressed,
required this.onNameSaved,
required this.onAddressChanged,
this.initialAddress = const Address(),
required this.nameLabel,
required this.nameHint,
required this.remarkController,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 회사명/지점명
FormFieldWrapper(
label: nameLabel,
isRequired: true,
child: CompanyNameAutocomplete(
nameController: nameController,
nameFocusNode: nameFocusNode,
companyNames: companyNames,
filteredCompanyNames: filteredCompanyNames,
showCompanyNameDropdown: showCompanyNameDropdown,
onCompanyNameSelected: onCompanyNameSelected,
onNameSaved: onNameSaved,
label: nameLabel,
hint: nameHint,
),
),
// 주소 입력 위젯 (SRP에 따라 별도 컴포넌트로 분리)
AddressInput(
initialZipCode: initialAddress.zipCode,
initialRegion: initialAddress.region,
initialDetailAddress: initialAddress.detailAddress,
isRequired: false,
onAddressChanged: (zipCode, region, detailAddress) {
final address = Address(
zipCode: zipCode,
region: region,
detailAddress: detailAddress,
);
onAddressChanged(address);
},
),
const SizedBox(height: 12),
// 비고 입력란
RemarkInput(controller: remarkController),
],
);
}
}

View File

@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/models/address_model.dart';
/// 회사/지점 정보를 1행(1열)로 보여주는 재활용 위젯
class CompanyInfoCard extends StatelessWidget {
final String title; // 본사/지점 구분
final String name;
final List<CompanyType> companyTypes;
final Address address;
final String? contactName;
final String? contactPosition;
final String? contactPhone;
final String? contactEmail;
const CompanyInfoCard({
super.key,
required this.title,
required this.name,
required this.companyTypes,
required this.address,
this.contactName,
this.contactPosition,
this.contactPhone,
this.contactEmail,
});
@override
Widget build(BuildContext context) {
// 각 데이터가 없으면 빈 문자열로 표기
final String zipCode = address.zipCode.isNotEmpty ? address.zipCode : '';
final String displayName = name.isNotEmpty ? name : '';
final String displayContactName =
contactName != null && contactName!.isNotEmpty ? contactName! : '';
final String displayContactPosition =
contactPosition != null && contactPosition!.isNotEmpty
? contactPosition!
: '';
final String displayContactPhone =
contactPhone != null && contactPhone!.isNotEmpty ? contactPhone! : '';
final String displayContactEmail =
contactEmail != null && contactEmail!.isNotEmpty ? contactEmail! : '';
return Card(
color: Colors.grey.shade50,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 본사/지점 구분만 상단에 표기 (텍스트 크기 14로 축소)
Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
const SizedBox(height: 2), // 간격도 절반으로 축소
// 1행(1열)로 데이터만 표기
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
Text(displayName, style: const TextStyle(fontSize: 13)),
const SizedBox(width: 12),
Text(zipCode, style: const TextStyle(fontSize: 13)),
const SizedBox(width: 12),
Text(
displayContactName,
style: const TextStyle(fontSize: 13),
),
const SizedBox(width: 12),
Text(
displayContactPosition,
style: const TextStyle(fontSize: 13),
),
const SizedBox(width: 12),
Text(
displayContactPhone,
style: const TextStyle(fontSize: 13),
),
const SizedBox(width: 12),
Text(
displayContactEmail,
style: const TextStyle(fontSize: 13),
),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,185 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:superport/utils/validators.dart';
class CompanyNameAutocomplete extends StatelessWidget {
final TextEditingController nameController;
final FocusNode nameFocusNode;
final List<String> companyNames;
final List<String> filteredCompanyNames;
final bool showCompanyNameDropdown;
final Function(String) onCompanyNameSelected;
final ValueChanged<String?> onNameSaved;
final String label;
final String hint;
const CompanyNameAutocomplete({
Key? key,
required this.nameController,
required this.nameFocusNode,
required this.companyNames,
required this.filteredCompanyNames,
required this.showCompanyNameDropdown,
required this.onCompanyNameSelected,
required this.onNameSaved,
required this.label,
required this.hint,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: nameController,
focusNode: nameFocusNode,
textInputAction: TextInputAction.next,
decoration: InputDecoration(
labelText: label,
hintText: hint,
suffixIcon:
nameController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
nameController.clear();
},
)
: IconButton(
icon: const Icon(Icons.arrow_drop_down),
onPressed: () {},
),
),
validator: (value) => validateRequired(value, label),
onFieldSubmitted: (_) {
if (filteredCompanyNames.length == 1 && showCompanyNameDropdown) {
onCompanyNameSelected(filteredCompanyNames[0]);
}
},
onTap: () {},
onSaved: onNameSaved,
),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
height:
showCompanyNameDropdown
? (filteredCompanyNames.length > 4
? 200
: filteredCompanyNames.length * 50.0)
: 0,
margin: EdgeInsets.only(top: showCompanyNameDropdown ? 4 : 0),
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: GestureDetector(
onTap: () {},
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.grey.shade300),
boxShadow: [
BoxShadow(
color: Colors.grey.withAlpha(77),
spreadRadius: 1,
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
child:
filteredCompanyNames.isEmpty
? const Padding(
padding: EdgeInsets.all(12.0),
child: Text('검색 결과가 없습니다'),
)
: ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: filteredCompanyNames.length,
separatorBuilder:
(context, index) => Divider(
height: 1,
color: Colors.grey.shade200,
),
itemBuilder: (context, index) {
final companyName = filteredCompanyNames[index];
final text = nameController.text.toLowerCase();
if (text.isEmpty) {
return ListTile(
dense: true,
title: Text(companyName),
onTap: () => onCompanyNameSelected(companyName),
);
}
// 일치하는 부분 찾기
final matchIndex = companyName
.toLowerCase()
.indexOf(text.toLowerCase());
if (matchIndex < 0) {
return ListTile(
dense: true,
title: Text(companyName),
onTap: () => onCompanyNameSelected(companyName),
);
}
return ListTile(
dense: true,
title: RichText(
text: TextSpan(
children: [
// 일치 이전 부분
if (matchIndex > 0)
TextSpan(
text: companyName.substring(
0,
matchIndex,
),
style: const TextStyle(
color: Colors.black,
),
),
// 일치하는 부분
TextSpan(
text: companyName.substring(
matchIndex,
matchIndex + text.length,
),
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
// 일치 이후 부분
if (matchIndex + text.length <
companyName.length)
TextSpan(
text: companyName.substring(
matchIndex + text.length,
),
style: TextStyle(
color:
matchIndex == 0
? Colors.grey[600]
: Colors.black,
),
),
],
),
),
onTap: () => onCompanyNameSelected(companyName),
);
},
),
),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/company/widgets/contact_info_widget.dart';
/// 담당자 정보 폼
///
/// 회사 등록 및 수정 화면에서 사용되는 담당자 정보 입력 폼
/// 내부적으로 공통 ContactInfoWidget을 사용하여 코드 재사용성 확보
class ContactInfoForm extends StatelessWidget {
final TextEditingController contactNameController;
final TextEditingController contactPositionController;
final TextEditingController contactPhoneController;
final TextEditingController contactEmailController;
final List<String> positions;
final String selectedPhonePrefix;
final List<String> phonePrefixes;
final ValueChanged<String> onPhonePrefixChanged;
final ValueChanged<String?> onNameSaved;
final ValueChanged<String?> onPositionSaved;
final ValueChanged<String?> onPhoneSaved;
final ValueChanged<String?> onEmailSaved;
const ContactInfoForm({
Key? key,
required this.contactNameController,
required this.contactPositionController,
required this.contactPhoneController,
required this.contactEmailController,
required this.positions,
required this.selectedPhonePrefix,
required this.phonePrefixes,
required this.onPhonePrefixChanged,
required this.onNameSaved,
required this.onPositionSaved,
required this.onPhoneSaved,
required this.onEmailSaved,
}) : super(key: key);
@override
Widget build(BuildContext context) {
// ContactInfoWidget을 사용하여 담당자 정보 UI 구성
return ContactInfoWidget(
contactNameController: contactNameController,
contactPositionController: contactPositionController,
contactPhoneController: contactPhoneController,
contactEmailController: contactEmailController,
positions: positions,
selectedPhonePrefix: selectedPhonePrefix,
phonePrefixes: phonePrefixes,
onPhonePrefixChanged: onPhonePrefixChanged,
// 각 콜백 함수를 ContactInfoWidget의 onChanged 콜백과 연결
onContactNameChanged: (value) => onNameSaved?.call(value),
onContactPositionChanged: (value) => onPositionSaved?.call(value),
onContactPhoneChanged: (value) => onPhoneSaved?.call(value),
onContactEmailChanged: (value) => onEmailSaved?.call(value),
);
}
}

View File

@@ -0,0 +1,702 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:developer' as developer;
import 'package:superport/screens/common/custom_widgets.dart';
import 'package:superport/utils/validators.dart';
import 'package:superport/utils/phone_utils.dart';
import 'dart:math' as math;
/// 담당자 정보 위젯
///
/// 회사 및 지점의 담당자 정보를 입력받는 공통 위젯
/// SRP(단일 책임 원칙)에 따라 담당자 정보 입력 로직을 분리
class ContactInfoWidget extends StatefulWidget {
/// 위젯 제목
final String title;
/// 담당자 이름 컨트롤러
final TextEditingController contactNameController;
/// 담당자 직책 컨트롤러
final TextEditingController contactPositionController;
/// 담당자 전화번호 컨트롤러
final TextEditingController contactPhoneController;
/// 담당자 이메일 컨트롤러
final TextEditingController contactEmailController;
/// 직책 목록
final List<String> positions;
/// 선택된 전화번호 접두사
final String selectedPhonePrefix;
/// 전화번호 접두사 목록
final List<String> phonePrefixes;
/// 직책 컴팩트 모드 (Row 또는 Column 레이아웃 결정)
final bool compactMode;
/// 전화번호 접두사 변경 콜백
final ValueChanged<String> onPhonePrefixChanged;
/// 담당자 이름 변경 콜백
final ValueChanged<String> onContactNameChanged;
/// 담당자 직책 변경 콜백
final ValueChanged<String> onContactPositionChanged;
/// 담당자 전화번호 변경 콜백
final ValueChanged<String> onContactPhoneChanged;
/// 담당자 이메일 변경 콜백
final ValueChanged<String> onContactEmailChanged;
const ContactInfoWidget({
Key? key,
this.title = '담당자 정보',
required this.contactNameController,
required this.contactPositionController,
required this.contactPhoneController,
required this.contactEmailController,
required this.positions,
required this.selectedPhonePrefix,
required this.phonePrefixes,
required this.onPhonePrefixChanged,
required this.onContactNameChanged,
required this.onContactPositionChanged,
required this.onContactPhoneChanged,
required this.onContactEmailChanged,
this.compactMode = false,
}) : super(key: key);
@override
State<ContactInfoWidget> createState() => _ContactInfoWidgetState();
}
class _ContactInfoWidgetState extends State<ContactInfoWidget> {
bool _showPositionDropdown = false;
bool _showPhonePrefixDropdown = false;
final LayerLink _positionLayerLink = LayerLink();
final LayerLink _phonePrefixLayerLink = LayerLink();
OverlayEntry? _positionOverlayEntry;
OverlayEntry? _phonePrefixOverlayEntry;
final FocusNode _positionFocusNode = FocusNode();
final FocusNode _phonePrefixFocusNode = FocusNode();
@override
void initState() {
super.initState();
developer.log('ContactInfoWidget 초기화 완료', name: 'ContactInfoWidget');
_positionFocusNode.addListener(() {
if (_positionFocusNode.hasFocus) {
developer.log('직책 필드 포커스 얻음', name: 'ContactInfoWidget');
} else {
developer.log('직책 필드 포커스 잃음', name: 'ContactInfoWidget');
}
});
_phonePrefixFocusNode.addListener(() {
if (_phonePrefixFocusNode.hasFocus) {
developer.log('전화번호 접두사 필드 포커스 얻음', name: 'ContactInfoWidget');
} else {
developer.log('전화번호 접두사 필드 포커스 잃음', name: 'ContactInfoWidget');
}
});
}
@override
void dispose() {
_removeAllOverlays();
_positionFocusNode.dispose();
_phonePrefixFocusNode.dispose();
super.dispose();
}
void _togglePositionDropdown() {
developer.log(
'직책 드롭다운 토글: $_showPositionDropdown -> ${!_showPositionDropdown}',
name: 'ContactInfoWidget',
);
setState(() {
if (_showPositionDropdown) {
_removePositionOverlay();
} else {
_showPositionDropdown = true;
_showPhonePrefixDropdown = false;
_removePhonePrefixOverlay();
_showPositionOverlay();
}
});
}
void _togglePhonePrefixDropdown() {
developer.log(
'전화번호 접두사 드롭다운 토글: $_showPhonePrefixDropdown -> ${!_showPhonePrefixDropdown}',
name: 'ContactInfoWidget',
);
setState(() {
if (_showPhonePrefixDropdown) {
_removePhonePrefixOverlay();
} else {
_showPhonePrefixDropdown = true;
_showPositionDropdown = false;
_removePositionOverlay();
_showPhonePrefixOverlay();
}
});
}
void _removePositionOverlay() {
_positionOverlayEntry?.remove();
_positionOverlayEntry = null;
_showPositionDropdown = false;
}
void _removePhonePrefixOverlay() {
_phonePrefixOverlayEntry?.remove();
_phonePrefixOverlayEntry = null;
_showPhonePrefixDropdown = false;
}
void _removeAllOverlays() {
_removePositionOverlay();
_removePhonePrefixOverlay();
}
void _closeAllDropdowns() {
if (_showPositionDropdown || _showPhonePrefixDropdown) {
developer.log('모든 드롭다운 닫기', name: 'ContactInfoWidget');
setState(() {
_removeAllOverlays();
});
}
}
void _showPositionOverlay() {
final RenderBox renderBox = context.findRenderObject() as RenderBox;
final size = renderBox.size;
final offset = renderBox.localToGlobal(Offset.zero);
final availableHeight =
MediaQuery.of(context).size.height - offset.dy - 100;
final maxHeight = math.min(300.0, availableHeight);
_positionOverlayEntry = OverlayEntry(
builder:
(context) => Positioned(
width: 200,
child: CompositedTransformFollower(
link: _positionLayerLink,
showWhenUnlinked: false,
offset: const Offset(0, 45),
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(4),
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),
spreadRadius: 1,
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
constraints: BoxConstraints(maxHeight: maxHeight),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
...widget.positions.map(
(position) => InkWell(
onTap: () {
developer.log(
'직책 선택됨: $position',
name: 'ContactInfoWidget',
);
setState(() {
widget.contactPositionController.text =
position;
widget.onContactPositionChanged(position);
_removePositionOverlay();
});
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
width: double.infinity,
child: Text(position),
),
),
),
InkWell(
onTap: () {
developer.log(
'직책 기타(직접 입력) 선택됨',
name: 'ContactInfoWidget',
);
_removePositionOverlay();
widget.contactPositionController.clear();
widget.onContactPositionChanged('');
_positionFocusNode.requestFocus();
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
width: double.infinity,
child: const Text('기타 (직접 입력)'),
),
),
],
),
),
),
),
),
),
);
Overlay.of(context).insert(_positionOverlayEntry!);
}
void _showPhonePrefixOverlay() {
final RenderBox renderBox = context.findRenderObject() as RenderBox;
final size = renderBox.size;
final offset = renderBox.localToGlobal(Offset.zero);
final availableHeight =
MediaQuery.of(context).size.height - offset.dy - 100;
final maxHeight = math.min(300.0, availableHeight);
_phonePrefixOverlayEntry = OverlayEntry(
builder:
(context) => Positioned(
width: 200,
child: CompositedTransformFollower(
link: _phonePrefixLayerLink,
showWhenUnlinked: false,
offset: const Offset(0, 45),
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(4),
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),
spreadRadius: 1,
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
constraints: BoxConstraints(maxHeight: maxHeight),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
...widget.phonePrefixes.map(
(prefix) => InkWell(
onTap: () {
developer.log(
'전화번호 접두사 선택됨: $prefix',
name: 'ContactInfoWidget',
);
widget.onPhonePrefixChanged(prefix);
setState(() {
_removePhonePrefixOverlay();
});
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
width: double.infinity,
child: Text(prefix),
),
),
),
InkWell(
onTap: () {
developer.log(
'전화번호 접두사 직접 입력 선택됨',
name: 'ContactInfoWidget',
);
_removePhonePrefixOverlay();
_phonePrefixFocusNode.requestFocus();
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
width: double.infinity,
child: const Text('기타 (직접 입력)'),
),
),
],
),
),
),
),
),
),
);
Overlay.of(context).insert(_phonePrefixOverlayEntry!);
}
@override
Widget build(BuildContext context) {
developer.log(
'ContactInfoWidget 빌드 시작: 직책 드롭다운=$_showPositionDropdown, 전화번호 접두사 드롭다운=$_showPhonePrefixDropdown',
name: 'ContactInfoWidget',
);
// 컴팩트 모드에 따라 다른 레이아웃 생성
return FormFieldWrapper(
label: widget.title,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children:
widget.compactMode ? _buildCompactLayout() : _buildDefaultLayout(),
),
);
}
// 기본 레이아웃 (한 줄에 모든 필드 표시)
List<Widget> _buildDefaultLayout() {
return [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 담당자 이름
Expanded(
flex: 3,
child: TextFormField(
controller: widget.contactNameController,
decoration: const InputDecoration(
hintText: '이름',
contentPadding: EdgeInsets.symmetric(
horizontal: 10,
vertical: 15,
),
),
onTap: () {
developer.log('이름 필드 터치됨', name: 'ContactInfoWidget');
_closeAllDropdowns();
},
onChanged: widget.onContactNameChanged,
),
),
const SizedBox(width: 8),
// 담당자 직책
Expanded(
flex: 2,
child: CompositedTransformTarget(
link: _positionLayerLink,
child: Stack(
children: [
TextFormField(
controller: widget.contactPositionController,
focusNode: _positionFocusNode,
decoration: InputDecoration(
hintText: '직책',
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 15,
),
suffixIcon: IconButton(
icon: const Icon(Icons.arrow_drop_down, size: 20),
padding: EdgeInsets.zero,
onPressed: () {
developer.log(
'직책 드롭다운 버튼 클릭됨',
name: 'ContactInfoWidget',
);
_togglePositionDropdown();
},
),
),
onTap: () {
// 필드를 터치했을 때는 드롭다운을 열지 않고 직접 입력 모드로 진입
_closeAllDropdowns();
},
onChanged: widget.onContactPositionChanged,
),
],
),
),
),
const SizedBox(width: 8),
// 전화번호 접두사
Expanded(
flex: 2,
child: CompositedTransformTarget(
link: _phonePrefixLayerLink,
child: Stack(
children: [
TextFormField(
controller: TextEditingController(
text: widget.selectedPhonePrefix,
),
focusNode: _phonePrefixFocusNode,
decoration: InputDecoration(
hintText: '국가번호',
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 15,
),
suffixIcon: IconButton(
icon: const Icon(Icons.arrow_drop_down, size: 20),
padding: EdgeInsets.zero,
onPressed: () {
developer.log(
'전화번호 접두사 드롭다운 버튼 클릭됨',
name: 'ContactInfoWidget',
);
_togglePhonePrefixDropdown();
},
),
),
onTap: () {
// 필드를 터치했을 때는 드롭다운을 열지 않고 직접 입력 모드로 진입
_closeAllDropdowns();
},
onChanged: (value) {
if (value.isNotEmpty) {
widget.onPhonePrefixChanged(value);
}
},
),
],
),
),
),
const SizedBox(width: 8),
// 담당자 전화번호
Expanded(
flex: 3,
child: TextFormField(
controller: widget.contactPhoneController,
decoration: const InputDecoration(
hintText: '전화번호',
contentPadding: EdgeInsets.symmetric(
horizontal: 10,
vertical: 15,
),
),
keyboardType: TextInputType.phone,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
PhoneUtils.phoneInputFormatter,
],
onTap: () {
developer.log('전화번호 필드 터치됨', name: 'ContactInfoWidget');
_closeAllDropdowns();
},
validator: validatePhoneNumber,
onChanged: widget.onContactPhoneChanged,
),
),
const SizedBox(width: 8),
// 담당자 이메일
Expanded(
flex: 6,
child: TextFormField(
controller: widget.contactEmailController,
decoration: const InputDecoration(
hintText: '이메일',
contentPadding: EdgeInsets.symmetric(
horizontal: 10,
vertical: 15,
),
),
keyboardType: TextInputType.emailAddress,
onTap: () {
developer.log('이메일 필드 터치됨', name: 'ContactInfoWidget');
_closeAllDropdowns();
},
validator: FormValidator.email(),
onChanged: widget.onContactEmailChanged,
),
),
],
),
];
}
// 컴팩트 레이아웃 (여러 줄에 필드 표시)
List<Widget> _buildCompactLayout() {
return [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 담당자 이름
Expanded(
child: TextFormField(
controller: widget.contactNameController,
decoration: const InputDecoration(
hintText: '담당자 이름',
contentPadding: EdgeInsets.symmetric(
horizontal: 10,
vertical: 15,
),
),
onTap: () {
developer.log('이름 필드 터치됨', name: 'ContactInfoWidget');
_closeAllDropdowns();
},
onChanged: widget.onContactNameChanged,
),
),
const SizedBox(width: 16),
// 담당자 직책
Expanded(
child: CompositedTransformTarget(
link: _positionLayerLink,
child: InkWell(
onTap: _togglePositionDropdown,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 15,
),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
widget.contactPositionController.text.isEmpty
? '직책 선택'
: widget.contactPositionController.text,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color:
widget.contactPositionController.text.isEmpty
? Colors.grey.shade600
: Colors.black,
),
),
),
const Icon(Icons.arrow_drop_down),
],
),
),
),
),
),
],
),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 전화번호 (접두사 + 번호)
Expanded(
child: Row(
children: [
// 전화번호 접두사
CompositedTransformTarget(
link: _phonePrefixLayerLink,
child: InkWell(
onTap: _togglePhonePrefixDropdown,
child: Container(
width: 70,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 14,
),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: const BorderRadius.horizontal(
left: Radius.circular(4),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
widget.selectedPhonePrefix,
style: const TextStyle(fontSize: 14),
),
const Icon(Icons.arrow_drop_down, size: 18),
],
),
),
),
),
// 전화번호
Expanded(
child: TextFormField(
controller: widget.contactPhoneController,
decoration: const InputDecoration(
hintText: '전화번호',
border: OutlineInputBorder(
borderRadius: BorderRadius.horizontal(
left: Radius.zero,
right: Radius.circular(4),
),
),
contentPadding: EdgeInsets.symmetric(
horizontal: 10,
vertical: 15,
),
),
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
PhoneUtils.phoneInputFormatter,
],
keyboardType: TextInputType.phone,
onTap: _closeAllDropdowns,
onChanged: widget.onContactPhoneChanged,
validator: validatePhoneNumber,
),
),
],
),
),
const SizedBox(width: 16),
// 이메일
Expanded(
child: TextFormField(
controller: widget.contactEmailController,
decoration: const InputDecoration(
hintText: '이메일 주소',
contentPadding: EdgeInsets.symmetric(
horizontal: 10,
vertical: 15,
),
),
keyboardType: TextInputType.emailAddress,
onTap: _closeAllDropdowns,
onChanged: widget.onContactEmailChanged,
validator: FormValidator.email(),
),
),
],
),
];
}
}

View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:superport/models/company_model.dart';
/// 중복된 회사명을 확인하는 대화상자
class DuplicateCompanyDialog extends StatelessWidget {
final Company company;
const DuplicateCompanyDialog({Key? key, required this.company})
: super(key: key);
static void show(BuildContext context, Company company) {
showDialog(
context: context,
builder: (context) => DuplicateCompanyDialog(company: company),
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('중복된 회사'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('동일한 이름의 회사가 이미 등록되어 있습니다.'),
const SizedBox(height: 16),
Text('회사명: ${company.name}'),
Text('주소: ${company.address ?? ''}'),
Text('담당자: ${company.contactName ?? ''}'),
Text('직책: ${company.contactPosition ?? ''}'),
Text('연락처: ${company.contactPhone ?? ''}'),
Text('이메일: ${company.contactEmail ?? ''}'),
const SizedBox(height: 8),
if (company.branches != null && company.branches!.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'지점 정보:',
style: TextStyle(fontWeight: FontWeight.bold),
),
...company.branches!.map(
(branch) => Padding(
padding: const EdgeInsets.only(left: 8.0, top: 4.0),
child: Text(
'${branch.name}: ${branch.address ?? ''} (담당자: ${branch.contactName ?? ''}, 직책: ${branch.contactPosition ?? ''}, 연락처: ${branch.contactPhone ?? ''})',
),
),
),
],
),
],
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('확인'),
),
],
);
}
}

View File

@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
/// 주소에 대한 지도 대화상자를 표시합니다.
class MapDialog extends StatelessWidget {
final String address;
const MapDialog({Key? key, required this.address}) : super(key: key);
static void show(BuildContext context, String address) {
if (address.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('주소를 먼저 입력해주세요.'),
duration: Duration(seconds: 2),
),
);
return;
}
showDialog(
context: context,
builder: (BuildContext context) {
return MapDialog(address: address);
},
);
}
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Container(
width: MediaQuery.of(context).size.width * 0.9,
height: MediaQuery.of(context).size.height * 0.7,
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'주소 지도 보기',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
const SizedBox(height: 8),
Text('주소: $address', style: const TextStyle(fontSize: 14)),
const SizedBox(height: 16),
Expanded(
child: Container(
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.map,
size: 64,
color: AppThemeTailwind.primary,
),
const SizedBox(height: 16),
Text(
'여기에 주소 "$address"에 대한\n지도가 표시됩니다.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade700),
),
const SizedBox(height: 24),
Text(
'실제 구현 시에는 Google Maps 또는\n다른 지도 서비스 API를 연동하세요.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
fontStyle: FontStyle.italic,
),
),
],
),
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,267 @@
import 'package:flutter/material.dart';
import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/utils/constants.dart';
/// 장비 입고 폼 컨트롤러
///
/// 폼의 전체 상태, 유효성, 저장, 데이터 로딩 등 비즈니스 로직을 담당한다.
class EquipmentInFormController {
final MockDataService dataService;
final int? equipmentInId;
// 폼 키
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
// 입력 상태 변수
String manufacturer = '';
String name = '';
String category = '';
String subCategory = '';
String subSubCategory = '';
String serialNumber = '';
String barcode = '';
int quantity = 1;
DateTime inDate = DateTime.now();
String equipmentType = EquipmentType.new_;
bool hasSerialNumber = true;
// 워런티 관련 상태
String? warrantyLicense;
String? warrantyCode; // 워런티 코드(텍스트 입력)
DateTime warrantyStartDate = DateTime.now();
DateTime warrantyEndDate = DateTime.now().add(const Duration(days: 365));
List<String> warrantyLicenses = [];
// 자동완성 데이터
List<String> manufacturers = [];
List<String> equipmentNames = [];
// 카테고리 자동완성 데이터
List<String> categories = [];
List<String> subCategories = [];
List<String> subSubCategories = [];
// 편집 모드 여부
bool isEditMode = false;
// 입고지, 파트너사 관련 상태
String? warehouseLocation;
String? partnerCompany;
List<String> warehouseLocations = [];
List<String> partnerCompanies = [];
final TextEditingController remarkController = TextEditingController();
EquipmentInFormController({required this.dataService, this.equipmentInId}) {
isEditMode = equipmentInId != null;
_loadManufacturers();
_loadEquipmentNames();
_loadCategories();
_loadSubCategories();
_loadSubSubCategories();
_loadWarehouseLocations();
_loadPartnerCompanies();
_loadWarrantyLicenses();
if (isEditMode) {
_loadEquipmentIn();
}
}
// 제조사 목록 로드
void _loadManufacturers() {
manufacturers = dataService.getAllManufacturers();
}
// 장비명 목록 로드
void _loadEquipmentNames() {
equipmentNames = dataService.getAllEquipmentNames();
}
// 카테고리 목록 로드
void _loadCategories() {
categories = dataService.getAllCategories();
}
// 서브카테고리 목록 로드
void _loadSubCategories() {
subCategories = dataService.getAllSubCategories();
}
// 서브서브카테고리 목록 로드
void _loadSubSubCategories() {
subSubCategories = dataService.getAllSubSubCategories();
}
// 입고지 목록 로드
void _loadWarehouseLocations() {
warehouseLocations =
dataService.getAllWarehouseLocations().map((e) => e.name).toList();
}
// 파트너사 목록 로드
void _loadPartnerCompanies() {
partnerCompanies =
dataService
.getAllCompanies()
.where((c) => c.companyTypes.contains(CompanyType.partner))
.map((c) => c.name)
.toList();
}
// 워런티 라이센스 목록 로드
void _loadWarrantyLicenses() {
// 실제로는 API나 서비스에서 불러와야 하지만, 파트너사와 동일한 데이터 사용
warrantyLicenses = List.from(partnerCompanies);
}
// 기존 데이터 로드(수정 모드)
void _loadEquipmentIn() {
final equipmentIn = dataService.getEquipmentInById(equipmentInId!);
if (equipmentIn != null) {
manufacturer = equipmentIn.equipment.manufacturer;
name = equipmentIn.equipment.name;
category = equipmentIn.equipment.category;
subCategory = equipmentIn.equipment.subCategory;
subSubCategory = equipmentIn.equipment.subSubCategory;
serialNumber = equipmentIn.equipment.serialNumber ?? '';
barcode = equipmentIn.equipment.barcode ?? '';
quantity = equipmentIn.equipment.quantity;
inDate = equipmentIn.inDate;
hasSerialNumber = serialNumber.isNotEmpty;
equipmentType = equipmentIn.type;
warehouseLocation = equipmentIn.warehouseLocation;
partnerCompany = equipmentIn.partnerCompany;
remarkController.text = equipmentIn.remark ?? '';
// 워런티 정보 로드 (실제 구현에서는 기존 값을 불러옵니다)
warrantyLicense = equipmentIn.partnerCompany; // 기본값으로 파트너사 이름 사용
warrantyStartDate = equipmentIn.inDate;
warrantyEndDate = equipmentIn.inDate.add(const Duration(days: 365));
// 워런티 코드도 불러오도록(실제 구현시)
warrantyCode = null; // TODO: 실제 데이터에서 불러올 경우 수정
}
}
// 워런티 기간 계산
String getWarrantyPeriodSummary() {
final difference = warrantyEndDate.difference(warrantyStartDate);
final days = difference.inDays;
if (days <= 0) {
return '유효하지 않은 기간';
}
final years = days ~/ 365;
final remainingDays = days % 365;
String summary = '';
if (years > 0) {
summary += '$years년 ';
}
if (remainingDays > 0) {
summary += '$remainingDays일';
}
return summary.trim();
}
// 저장 처리
bool save() {
if (!formKey.currentState!.validate()) {
return false;
}
formKey.currentState!.save();
// 입력값이 리스트에 없으면 추가
if (partnerCompany != null &&
partnerCompany!.isNotEmpty &&
!partnerCompanies.contains(partnerCompany)) {
partnerCompanies.add(partnerCompany!);
}
if (warehouseLocation != null &&
warehouseLocation!.isNotEmpty &&
!warehouseLocations.contains(warehouseLocation)) {
warehouseLocations.add(warehouseLocation!);
}
if (manufacturer.isNotEmpty && !manufacturers.contains(manufacturer)) {
manufacturers.add(manufacturer);
}
if (name.isNotEmpty && !equipmentNames.contains(name)) {
equipmentNames.add(name);
}
if (category.isNotEmpty && !categories.contains(category)) {
categories.add(category);
}
if (subCategory.isNotEmpty && !subCategories.contains(subCategory)) {
subCategories.add(subCategory);
}
if (subSubCategory.isNotEmpty &&
!subSubCategories.contains(subSubCategory)) {
subSubCategories.add(subSubCategory);
}
if (warrantyLicense != null &&
warrantyLicense!.isNotEmpty &&
!warrantyLicenses.contains(warrantyLicense)) {
warrantyLicenses.add(warrantyLicense!);
}
final equipment = Equipment(
manufacturer: manufacturer,
name: name,
category: category,
subCategory: subCategory,
subSubCategory: subSubCategory,
serialNumber: hasSerialNumber ? serialNumber : null,
barcode: barcode.isNotEmpty ? barcode : null,
quantity: quantity,
remark: remarkController.text.trim(),
warrantyLicense: warrantyLicense,
warrantyStartDate: warrantyStartDate,
warrantyEndDate: warrantyEndDate,
// 워런티 코드 저장 필요시 여기에 추가
);
if (isEditMode) {
final equipmentIn = dataService.getEquipmentInById(equipmentInId!);
if (equipmentIn != null) {
final updatedEquipmentIn = EquipmentIn(
id: equipmentIn.id,
equipment: equipment,
inDate: inDate,
status: equipmentIn.status,
type: equipmentType,
warehouseLocation: warehouseLocation,
partnerCompany: partnerCompany,
remark: remarkController.text.trim(),
);
dataService.updateEquipmentIn(updatedEquipmentIn);
}
} else {
final newEquipmentIn = EquipmentIn(
equipment: equipment,
inDate: inDate,
type: equipmentType,
warehouseLocation: warehouseLocation,
partnerCompany: partnerCompany,
remark: remarkController.text.trim(),
);
dataService.addEquipmentIn(newEquipmentIn);
}
// 저장 후 리스트 재로딩 (중복 방지 및 최신화)
_loadManufacturers();
_loadEquipmentNames();
_loadCategories();
_loadSubCategories();
_loadSubSubCategories();
_loadWarehouseLocations();
_loadPartnerCompanies();
_loadWarrantyLicenses();
return true;
}
void dispose() {
remarkController.dispose();
}
}

View File

@@ -0,0 +1,170 @@
import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/utils/constants.dart';
// companyTypeToString 함수 import
import 'package:superport/utils/constants.dart'
show companyTypeToString, CompanyType;
import 'package:superport/models/company_model.dart';
import 'package:superport/models/address_model.dart';
// 장비 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러
class EquipmentListController {
final MockDataService dataService;
List<UnifiedEquipment> equipments = [];
String? selectedStatusFilter;
final Set<String> selectedEquipmentIds = {}; // 'id:status' 형식
EquipmentListController({required this.dataService});
// 데이터 로드 및 상태 필터 적용
void loadData() {
equipments = dataService.getAllEquipments();
if (selectedStatusFilter != null) {
equipments =
equipments.where((e) => e.status == selectedStatusFilter).toList();
}
selectedEquipmentIds.clear();
}
// 상태 필터 변경
void changeStatusFilter(String? status) {
selectedStatusFilter = status;
loadData();
}
// 장비 선택/해제 (모든 상태 지원)
void selectEquipment(int? id, String status, bool? isSelected) {
if (id == null || isSelected == null) return;
final key = '$id:$status';
if (isSelected) {
selectedEquipmentIds.add(key);
} else {
selectedEquipmentIds.remove(key);
}
}
// 선택된 입고 장비 수 반환
int getSelectedInStockCount() {
int count = 0;
for (final idStatusPair in selectedEquipmentIds) {
final parts = idStatusPair.split(':');
if (parts.length == 2 && parts[1] == EquipmentStatus.in_) {
count++;
}
}
return count;
}
// 선택된 전체 장비 수 반환
int getSelectedEquipmentCount() {
return selectedEquipmentIds.length;
}
// 선택된 특정 상태의 장비 수 반환
int getSelectedEquipmentCountByStatus(String status) {
int count = 0;
for (final idStatusPair in selectedEquipmentIds) {
final parts = idStatusPair.split(':');
if (parts.length == 2 && parts[1] == status) {
count++;
}
}
return count;
}
// 선택된 장비들의 UnifiedEquipment 객체 목록 반환
List<UnifiedEquipment> getSelectedEquipments() {
List<UnifiedEquipment> selected = [];
for (final idStatusPair in selectedEquipmentIds) {
final parts = idStatusPair.split(':');
if (parts.length == 2) {
final id = int.tryParse(parts[0]);
if (id != null) {
final equipment = equipments.firstWhere(
(e) => e.id == id && e.status == parts[1],
orElse: () => null as UnifiedEquipment,
);
if (equipment != null) {
selected.add(equipment);
}
}
}
}
return selected;
}
// 선택된 특정 상태의 장비들의 UnifiedEquipment 객체 목록 반환
List<UnifiedEquipment> getSelectedEquipmentsByStatus(String status) {
List<UnifiedEquipment> selected = [];
for (final idStatusPair in selectedEquipmentIds) {
final parts = idStatusPair.split(':');
if (parts.length == 2 && parts[1] == status) {
final id = int.tryParse(parts[0]);
if (id != null) {
final equipment = equipments.firstWhere(
(e) => e.id == id && e.status == status,
orElse: () => null as UnifiedEquipment,
);
if (equipment != null) {
selected.add(equipment);
}
}
}
}
return selected;
}
// 선택된 장비들의 요약 정보를 Map 형태로 반환 (출고/대여/폐기 폼에서 사용)
List<Map<String, dynamic>> getSelectedEquipmentsSummary() {
List<Map<String, dynamic>> summaryList = [];
List<UnifiedEquipment> selectedEquipmentsInStock =
getSelectedEquipmentsByStatus(EquipmentStatus.in_);
for (final equipment in selectedEquipmentsInStock) {
summaryList.add({
'equipment': equipment.equipment,
'equipmentInId': equipment.id,
'status': equipment.status,
});
}
return summaryList;
}
// 출고 정보(회사, 담당자, 라이센스 등) 반환
String getOutEquipmentInfo(int equipmentId, String infoType) {
final equipmentOut = dataService.getEquipmentOutById(equipmentId);
if (equipmentOut != null) {
switch (infoType) {
case 'company':
final company = equipmentOut.company ?? '-';
if (company != '-') {
final companyObj = dataService.getAllCompanies().firstWhere(
(c) => c.name == company,
orElse:
() => Company(
name: company,
address: Address(),
companyTypes: [CompanyType.customer], // 기본값 고객사
),
);
// 여러 유형 중 첫 번째만 표시 (대표 유형)
final typeText =
companyObj.companyTypes.isNotEmpty
? companyTypeToString(companyObj.companyTypes.first)
: '-';
return '$company (${typeText})';
}
return company;
case 'manager':
return equipmentOut.manager ?? '-';
case 'license':
return equipmentOut.license ?? '-';
default:
return '-';
}
}
return '-';
}
}

View File

@@ -0,0 +1,645 @@
import 'package:flutter/material.dart';
import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/models/address_model.dart';
import 'package:superport/services/mock_data_service.dart';
// 장비 출고 폼의 상태 및 비즈니스 로직을 담당하는 컨트롤러
class EquipmentOutFormController {
final MockDataService dataService;
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
final TextEditingController remarkController = TextEditingController();
// 상태 변수
bool isEditMode = false;
String manufacturer = '';
String name = '';
String category = '';
String subCategory = '';
String subSubCategory = '';
String serialNumber = '';
String barcode = '';
int quantity = 1;
DateTime outDate = DateTime.now();
bool hasSerialNumber = false;
DateTime? inDate;
String returnType = '재입고';
DateTime returnDate = DateTime.now();
bool hasManagers = false;
// 출고 유형(출고/대여/폐기) 상태 변수 추가
String outType = '출고'; // 기본값은 '출고'
// 기존 필드 - 호환성을 위해 유지
String? _selectedCompany;
String? get selectedCompany =>
selectedCompanies.isNotEmpty ? selectedCompanies[0] : null;
set selectedCompany(String? value) {
if (selectedCompanies.isEmpty) {
selectedCompanies.add(value);
} else {
selectedCompanies[0] = value;
}
_selectedCompany = value;
}
String? _selectedManager;
String? get selectedManager =>
selectedManagersPerCompany.isNotEmpty
? selectedManagersPerCompany[0]
: null;
set selectedManager(String? value) {
if (selectedManagersPerCompany.isEmpty) {
selectedManagersPerCompany.add(value);
} else {
selectedManagersPerCompany[0] = value;
}
_selectedManager = value;
}
String? selectedLicense;
List<String> companies = [];
// 회사 및 지점 관련 데이터
List<CompanyBranchInfo> companiesWithBranches = [];
List<String> managers = [];
List<String> filteredManagers = [];
List<String> licenses = [];
// 출고 회사 목록 관리
List<String?> selectedCompanies = [null]; // 첫 번째 드롭다운을 위한 초기값
List<List<String>> availableCompaniesPerDropdown =
[]; // 각 드롭다운마다 사용 가능한 회사 목록
List<String?> selectedManagersPerCompany = [null]; // 각 드롭다운 회사별 선택된 담당자
List<List<String>> filteredManagersPerCompany = []; // 각 드롭다운 회사별 필터링된 담당자 목록
List<bool> hasManagersPerCompany = [false]; // 각 회사별 담당자 유무
// 입력 데이터
Equipment? selectedEquipment;
int? selectedEquipmentInId;
int? equipmentOutId;
List<Map<String, dynamic>>? _selectedEquipments;
EquipmentOutFormController({required this.dataService});
// 선택된 장비 정보 설정 (디버그용)
set selectedEquipments(List<Map<String, dynamic>>? equipments) {
debugPrint('설정된 장비 목록: ${equipments?.length ?? 0}');
if (equipments != null) {
for (var i = 0; i < equipments.length; i++) {
final equipment = equipments[i]['equipment'] as Equipment;
debugPrint('장비 $i: ${equipment.manufacturer} ${equipment.name}');
}
}
_selectedEquipments = equipments;
}
List<Map<String, dynamic>>? get selectedEquipments => _selectedEquipments;
// 드롭다운 데이터 로드
void loadDropdownData() {
final allCompanies = dataService.getAllCompanies();
// 회사와 지점 통합 목록 생성
companiesWithBranches = [];
companies = [];
for (var company in allCompanies) {
// 회사 자체 정보 추가
final companyType =
company.companyTypes.isNotEmpty
? companyTypeToString(company.companyTypes.first)
: '-';
final companyInfo = CompanyBranchInfo(
id: company.id,
name: "${company.name} (${companyType})",
originalName: company.name,
isMainCompany: true,
companyId: company.id,
branchId: null,
);
companiesWithBranches.add(companyInfo);
companies.add(companyInfo.name);
// 지점 정보 추가
if (company.branches != null && company.branches!.isNotEmpty) {
for (var branch in company.branches!) {
final branchInfo = CompanyBranchInfo(
id: branch.id,
name: "${company.name} ${branch.name}",
displayName: branch.name,
originalName: branch.name,
isMainCompany: false,
companyId: company.id,
branchId: branch.id,
parentCompanyName: company.name,
);
companiesWithBranches.add(branchInfo);
companies.add(branchInfo.name);
}
}
}
// 나머지 데이터 로드
final allUsers = dataService.getAllUsers();
managers = allUsers.map((user) => user.name).toList();
filteredManagers = managers;
final allLicenses = dataService.getAllLicenses();
licenses = allLicenses.map((license) => license.name).toList();
if (companies.isEmpty) companies.add('기타');
if (managers.isEmpty) managers.add('기타');
if (licenses.isEmpty) licenses.add('기타');
updateManagersState();
// 출고 회사 드롭다운 초기화
availableCompaniesPerDropdown = [List.from(companies)];
filteredManagersPerCompany = [List.from(managers)];
hasManagersPerCompany = [hasManagers];
// 디버그 정보 출력
debugPrint('드롭다운 데이터 로드 완료');
debugPrint('장비 목록: ${_selectedEquipments?.length ?? 0}');
debugPrint('회사 및 지점 목록: ${companiesWithBranches.length}');
// 수정 모드인 경우 기존 선택값 동기화
if (isEditMode && equipmentOutId != null) {
final equipmentOut = dataService.getEquipmentOutById(equipmentOutId!);
if (equipmentOut != null && equipmentOut.company != null) {
String companyName = '';
// 회사 이름 찾기
for (String company in companies) {
if (company.startsWith(equipmentOut.company!)) {
companyName = company;
break;
}
}
if (companyName.isNotEmpty) {
selectedCompanies[0] = companyName;
filterManagersByCompanyAtIndex(companyName, 0);
// 기존 담당자 설정
if (equipmentOut.manager != null) {
selectedManagersPerCompany[0] = equipmentOut.manager;
}
}
// 라이센스 설정
if (equipmentOut.license != null) {
selectedLicense = equipmentOut.license;
}
}
}
}
// 회사에 따라 담당자 목록 필터링
void filterManagersByCompany(String? companyName) {
if (companyName == null || companyName.isEmpty) {
filteredManagers = managers;
} else {
// 회사 또는 지점 이름에서 CompanyBranchInfo 찾기
CompanyBranchInfo? companyInfo = _findCompanyInfoByDisplayName(
companyName,
);
if (companyInfo != null && companyInfo.companyId != null) {
int companyId = companyInfo.companyId!;
final companyUsers =
dataService
.getAllUsers()
.where((user) => user.companyId == companyId)
.toList();
if (companyUsers.isNotEmpty) {
filteredManagers = companyUsers.map((user) => user.name).toList();
} else {
filteredManagers = ['없음'];
}
} else {
filteredManagers = ['없음'];
}
}
if (selectedManager != null &&
!filteredManagers.contains(selectedManager)) {
selectedManager =
filteredManagers.isNotEmpty ? filteredManagers[0] : null;
}
updateManagersState();
// 첫 번째 회사에 대한 담당자 목록과 동기화
if (filteredManagersPerCompany.isNotEmpty) {
filteredManagersPerCompany[0] = List.from(filteredManagers);
hasManagersPerCompany[0] = hasManagers;
if (selectedManagersPerCompany.isNotEmpty) {
selectedManagersPerCompany[0] = selectedManager;
}
}
}
// 특정 인덱스의 회사에 따라 담당자 목록 필터링
void filterManagersByCompanyAtIndex(String? companyName, int index) {
if (companyName == null || companyName.isEmpty) {
filteredManagersPerCompany[index] = managers;
} else {
// 회사 또는 지점 이름에서 CompanyBranchInfo 찾기
CompanyBranchInfo? companyInfo = _findCompanyInfoByDisplayName(
companyName,
);
if (companyInfo != null && companyInfo.companyId != null) {
int companyId = companyInfo.companyId!;
final companyUsers =
dataService
.getAllUsers()
.where((user) => user.companyId == companyId)
.toList();
if (companyUsers.isNotEmpty) {
filteredManagersPerCompany[index] =
companyUsers.map((user) => user.name).toList();
} else {
filteredManagersPerCompany[index] = ['없음'];
}
} else {
filteredManagersPerCompany[index] = ['없음'];
}
}
if (selectedManagersPerCompany[index] != null &&
!filteredManagersPerCompany[index].contains(
selectedManagersPerCompany[index],
)) {
selectedManagersPerCompany[index] =
filteredManagersPerCompany[index].isNotEmpty
? filteredManagersPerCompany[index][0]
: null;
}
updateManagersStateAtIndex(index);
// 첫 번째 회사인 경우 기존 필드와 동기화
if (index == 0) {
filteredManagers = List.from(filteredManagersPerCompany[0]);
hasManagers = hasManagersPerCompany[0];
_selectedManager = selectedManagersPerCompany[0];
}
}
// 담당자 있는지 상태 업데이트
void updateManagersState() {
hasManagers =
filteredManagers.isNotEmpty &&
!(filteredManagers.length == 1 && filteredManagers[0] == '없음');
}
// 특정 인덱스의 담당자 상태 업데이트
void updateManagersStateAtIndex(int index) {
hasManagersPerCompany[index] =
filteredManagersPerCompany[index].isNotEmpty &&
!(filteredManagersPerCompany[index].length == 1 &&
filteredManagersPerCompany[index][0] == '없음');
}
// 출고 회사 추가
void addCompany() {
// 이미 선택된 회사 제외한 리스트 생성
List<String> availableCompanies = List.from(companies);
for (String? company in selectedCompanies) {
if (company != null) {
availableCompanies.remove(company);
}
}
// 새 드롭다운 추가
selectedCompanies.add(null);
availableCompaniesPerDropdown.add(availableCompanies);
selectedManagersPerCompany.add(null);
filteredManagersPerCompany.add(List.from(managers));
hasManagersPerCompany.add(false);
}
// 가능한 회사 목록 업데이트
void updateAvailableCompanies() {
// 각 드롭다운에 대해 사용 가능한 회사 목록 업데이트
for (int i = 0; i < selectedCompanies.length; i++) {
List<String> availableCompanies = List.from(companies);
// 이미 선택된 회사 제외
for (int j = 0; j < selectedCompanies.length; j++) {
if (i != j && selectedCompanies[j] != null) {
availableCompanies.remove(selectedCompanies[j]);
}
}
availableCompaniesPerDropdown[i] = availableCompanies;
}
}
// 선택 장비로 초기화
void initializeWithSelectedEquipment(Equipment equipment) {
manufacturer = equipment.manufacturer;
name = equipment.name;
category = equipment.category;
subCategory = equipment.subCategory;
subSubCategory = equipment.subSubCategory;
serialNumber = equipment.serialNumber ?? '';
barcode = equipment.barcode ?? '';
quantity = equipment.quantity;
hasSerialNumber = serialNumber.isNotEmpty;
inDate = equipment.inDate;
remarkController.text = equipment.remark ?? '';
}
// 회사/지점 표시 이름을 통해 CompanyBranchInfo 객체 찾기
CompanyBranchInfo? _findCompanyInfoByDisplayName(String displayName) {
for (var info in companiesWithBranches) {
if (info.name == displayName) {
return info;
}
}
return null;
}
// 출고 정보 저장 (UI에서 호출)
void saveEquipmentOut(Function(String) onSuccess, Function(String) onError) {
if (formKey.currentState?.validate() != true) {
onError('폼 유효성 검사 실패');
return;
}
formKey.currentState?.save();
// 선택된 회사가 없는지 확인
bool hasAnySelectedCompany = selectedCompanies.any(
(company) => company != null,
);
if (!hasAnySelectedCompany) {
onError('최소 하나의 출고 회사를 선택해주세요');
return;
}
// 기존 방식으로 첫 번째 회사 정보 처리
String? companyName;
if (selectedCompanies.isNotEmpty && selectedCompanies[0] != null) {
CompanyBranchInfo? companyInfo = _findCompanyInfoByDisplayName(
selectedCompanies[0]!,
);
if (companyInfo != null) {
companyName =
companyInfo.isMainCompany
? companyInfo
.originalName // 본사인 경우 회사 원래 이름
: "${companyInfo.originalName} (${companyInfo.branchId})"; // 지점인 경우 지점 정보 포함
} else {
companyName = selectedCompanies[0]!.replaceAll(
RegExp(r' \(.*\)\$'),
'',
);
}
} else {
onError('최소 하나의 출고 회사를 선택해주세요');
return;
}
if (isEditMode && equipmentOutId != null) {
final equipmentOut = dataService.getEquipmentOutById(equipmentOutId!);
if (equipmentOut != null) {
final updatedEquipmentOut = EquipmentOut(
id: equipmentOut.id,
equipment: equipmentOut.equipment,
outDate: equipmentOut.outDate,
status: returnType == '재입고' ? 'I' : 'R',
company: companyName,
manager: equipmentOut.manager,
license: equipmentOut.license,
returnDate: returnDate,
returnType: returnType,
remark: remarkController.text.trim(),
);
dataService.updateEquipmentOut(updatedEquipmentOut);
onSuccess('장비 출고 상태 변경 완료');
} else {
onError('출고 정보를 찾을 수 없습니다');
}
} else {
if (selectedEquipments != null && selectedEquipments!.isNotEmpty) {
// 여러 회사에 각각 출고 처리
List<String> successCompanies = [];
// 선택된 모든 회사에 대해 출고 처리
for (int i = 0; i < selectedCompanies.length; i++) {
if (selectedCompanies[i] == null) continue;
CompanyBranchInfo? companyInfo = _findCompanyInfoByDisplayName(
selectedCompanies[i]!,
);
String curCompanyName;
if (companyInfo != null) {
curCompanyName =
companyInfo.isMainCompany
? companyInfo
.originalName // 본사인 경우 회사 원래 이름
: "${companyInfo.originalName} (${companyInfo.branchId})"; // 지점인 경우 지점 정보 포함
} else {
curCompanyName = selectedCompanies[i]!.replaceAll(
RegExp(r' \(.*\)\$'),
'',
);
}
String? curManager = selectedManagersPerCompany[i];
if (curManager == null || curManager == '없음') {
// 담당자 없는 회사는 건너뛰기
continue;
}
// 해당 회사에 모든 장비 출고 처리
for (final equipmentData in selectedEquipments!) {
final equipment = equipmentData['equipment'] as Equipment;
final equipmentInId = equipmentData['equipmentInId'] as int;
final newEquipmentOut = EquipmentOut(
equipment: equipment,
outDate: outDate,
company: curCompanyName,
manager: curManager,
license: selectedLicense,
remark: remarkController.text.trim(),
);
dataService.changeEquipmentStatus(equipmentInId, newEquipmentOut);
}
successCompanies.add(companyInfo?.name ?? curCompanyName);
}
if (successCompanies.isEmpty) {
onError('모든 회사에 담당자가 없어 출고 처리할 수 없습니다');
} else {
onSuccess('${successCompanies.join(", ")} 회사로 다중 장비 출고 처리 완료');
}
} else if (selectedEquipmentInId != null) {
final equipment = Equipment(
manufacturer: manufacturer,
name: name,
category: category,
subCategory: subCategory,
subSubCategory: subSubCategory,
serialNumber: (hasSerialNumber) ? serialNumber : null,
barcode: barcode.isNotEmpty ? barcode : null,
quantity: quantity,
inDate: inDate,
remark: remarkController.text.trim(),
);
// 선택된 모든 회사에 대해 출고 처리
List<String> successCompanies = [];
for (int i = 0; i < selectedCompanies.length; i++) {
if (selectedCompanies[i] == null) continue;
CompanyBranchInfo? companyInfo = _findCompanyInfoByDisplayName(
selectedCompanies[i]!,
);
String curCompanyName;
if (companyInfo != null) {
curCompanyName =
companyInfo.isMainCompany
? companyInfo
.originalName // 본사인 경우 회사 원래 이름
: "${companyInfo.originalName} (${companyInfo.branchId})"; // 지점인 경우 지점 정보 포함
} else {
curCompanyName = selectedCompanies[i]!.replaceAll(
RegExp(r' \(.*\)\$'),
'',
);
}
String? curManager = selectedManagersPerCompany[i];
if (curManager == null || curManager == '없음') {
// 담당자 없는 회사는 건너뛰기
continue;
}
final newEquipmentOut = EquipmentOut(
equipment: equipment,
outDate: outDate,
company: curCompanyName,
manager: curManager,
license: selectedLicense,
remark: remarkController.text.trim(),
);
dataService.changeEquipmentStatus(
selectedEquipmentInId!,
newEquipmentOut,
);
successCompanies.add(companyInfo?.name ?? curCompanyName);
break; // 한 장비는 한 회사에만 출고
}
if (successCompanies.isEmpty) {
onError('모든 회사에 담당자가 없어 출고 처리할 수 없습니다');
} else {
onSuccess('${successCompanies.join(", ")} 회사로 장비 출고 처리 완료');
}
} else {
final equipment = Equipment(
manufacturer: manufacturer,
name: name,
category: category,
subCategory: subCategory,
subSubCategory: subSubCategory,
serialNumber: null,
barcode: null,
quantity: 1,
inDate: inDate,
remark: remarkController.text.trim(),
);
// 선택된 모든 회사에 대해 출고 처리
List<String> successCompanies = [];
for (int i = 0; i < selectedCompanies.length; i++) {
if (selectedCompanies[i] == null) continue;
CompanyBranchInfo? companyInfo = _findCompanyInfoByDisplayName(
selectedCompanies[i]!,
);
String curCompanyName;
if (companyInfo != null) {
curCompanyName =
companyInfo.isMainCompany
? companyInfo
.originalName // 본사인 경우 회사 원래 이름
: "${companyInfo.originalName} (${companyInfo.branchId})"; // 지점인 경우 지점 정보 포함
} else {
curCompanyName = selectedCompanies[i]!.replaceAll(
RegExp(r' \(.*\)\$'),
'',
);
}
String? curManager = selectedManagersPerCompany[i];
if (curManager == null || curManager == '없음') {
// 담당자 없는 회사는 건너뛰기
continue;
}
final newEquipmentOut = EquipmentOut(
equipment: equipment,
outDate: outDate,
company: curCompanyName,
manager: curManager,
license: selectedLicense,
remark: remarkController.text.trim(),
);
dataService.addEquipmentOut(newEquipmentOut);
successCompanies.add(companyInfo?.name ?? curCompanyName);
}
if (successCompanies.isEmpty) {
onError('모든 회사에 담당자가 없어 출고 처리할 수 없습니다');
} else {
onSuccess('${successCompanies.join(", ")} 회사로 새 출고 장비 추가 완료');
}
}
}
}
// 날짜 포맷 유틸리티
String formatDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
void dispose() {
remarkController.dispose();
}
}
/// 회사 및 지점 정보를 저장하는 클래스
class CompanyBranchInfo {
final int? id;
final String name; // 표시용 이름 (회사명 + 지점명 또는 회사명 (유형))
final String originalName; // 원래 이름 (회사 본사명 또는 지점명)
final String? displayName; // UI에 표시할 이름 (주로 지점명)
final bool isMainCompany; // 본사인지 지점인지 구분
final int? companyId; // 회사 ID
final int? branchId; // 지점 ID
final String? parentCompanyName; // 부모 회사명 (지점인 경우)
CompanyBranchInfo({
required this.id,
required this.name,
required this.originalName,
this.displayName,
required this.isMainCompany,
required this.companyId,
required this.branchId,
this.parentCompanyName,
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,696 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/equipment/controllers/equipment_list_controller.dart';
import 'package:superport/screens/equipment/widgets/equipment_table.dart';
import 'package:superport/utils/equipment_display_helper.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
import 'package:superport/screens/common/main_layout.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/screens/common/widgets/pagination.dart';
// 장비 목록 화면 (UI만 담당, 상태/로직/헬퍼/위젯 분리)
class EquipmentListScreen extends StatefulWidget {
final String currentRoute;
const EquipmentListScreen({super.key, this.currentRoute = Routes.equipment});
@override
State<EquipmentListScreen> createState() => _EquipmentListScreenState();
}
class _EquipmentListScreenState extends State<EquipmentListScreen> {
late final EquipmentListController _controller;
bool _showDetailedColumns = true;
final ScrollController _horizontalScrollController = ScrollController();
final ScrollController _verticalScrollController = ScrollController();
int _currentPage = 1;
final int _pageSize = 10;
String _searchKeyword = '';
String _appliedSearchKeyword = '';
@override
void initState() {
super.initState();
_controller = EquipmentListController(dataService: MockDataService());
_controller.loadData();
WidgetsBinding.instance.addPostFrameCallback((_) {
_adjustColumnsForScreenSize();
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_setDefaultFilterByRoute();
}
@override
void didUpdateWidget(EquipmentListScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.currentRoute != widget.currentRoute) {
_setDefaultFilterByRoute();
}
}
@override
void dispose() {
_horizontalScrollController.dispose();
_verticalScrollController.dispose();
super.dispose();
}
// 라우트에 따라 기본 필터 설정
void _setDefaultFilterByRoute() {
String? newFilter;
if (widget.currentRoute == Routes.equipmentInList) {
newFilter = EquipmentStatus.in_;
} else if (widget.currentRoute == Routes.equipmentOutList) {
newFilter = EquipmentStatus.out;
} else if (widget.currentRoute == Routes.equipmentRentList) {
newFilter = EquipmentStatus.rent;
} else if (widget.currentRoute == Routes.equipment) {
newFilter = null;
}
if ((newFilter != _controller.selectedStatusFilter) ||
widget.currentRoute != Routes.equipment) {
setState(() {
_controller.selectedStatusFilter = newFilter;
_controller.loadData();
});
}
}
// 화면 크기에 따라 컬럼 표시 조정
void _adjustColumnsForScreenSize() {
final width = MediaQuery.of(context).size.width;
setState(() {
_showDetailedColumns = width > 900;
});
}
// 상태 필터 변경
void _onStatusFilterChanged(String? status) {
setState(() {
_controller.changeStatusFilter(status);
});
}
// 장비 선택/해제
void _onEquipmentSelected(int? id, String status, bool? isSelected) {
setState(() {
_controller.selectEquipment(id, status, isSelected);
});
}
// 출고 처리 버튼 핸들러
void _handleOutEquipment() async {
if (_controller.getSelectedInStockCount() == 0) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('출고할 장비를 선택해주세요.')));
return;
}
// 선택된 장비들의 요약 정보를 가져와서 출고 폼으로 전달
final selectedEquipmentsSummary =
_controller.getSelectedEquipmentsSummary();
final result = await Navigator.pushNamed(
context,
Routes.equipmentOutAdd,
arguments: {'selectedEquipments': selectedEquipmentsSummary},
);
if (result == true) {
setState(() {
_controller.loadData();
});
}
}
// 대여 처리 버튼 핸들러
void _handleRentEquipment() async {
if (_controller.getSelectedInStockCount() == 0) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('대여할 장비를 선택해주세요.')));
return;
}
// 선택된 장비들의 요약 정보를 가져와서 대여 폼으로 전달
final selectedEquipmentsSummary =
_controller.getSelectedEquipmentsSummary();
// 현재는 대여 기능이 준비되지 않았으므로 간단히 스낵바 표시
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${selectedEquipmentsSummary.length}개 장비 대여 기능은 준비 중입니다.',
),
),
);
}
// 폐기 처리 버튼 핸들러
void _handleDisposeEquipment() {
if (_controller.getSelectedInStockCount() == 0) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('폐기할 장비를 선택해주세요.')));
return;
}
// 선택된 장비들의 요약 정보를 가져옴
final selectedEquipmentsSummary =
_controller.getSelectedEquipmentsSummary();
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: const Text('폐기 확인'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'선택한 ${selectedEquipmentsSummary.length}개 장비를 폐기하시겠습니까?',
),
const SizedBox(height: 16),
const Text(
'폐기할 장비 목록:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
...selectedEquipmentsSummary.map((equipmentData) {
final equipment = equipmentData['equipment'] as Equipment;
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
'${equipment.manufacturer} ${equipment.name} (${equipment.quantity}개)',
style: const TextStyle(fontSize: 14),
),
);
}).toList(),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소'),
),
TextButton(
onPressed: () {
// 여기에 폐기 로직 추가 예정
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('폐기 기능은 준비 중입니다.')),
);
Navigator.pop(context);
},
child: const Text('폐기'),
),
],
),
);
}
// 카테고리 축약 표기 함수 (예: 컴... > 태... > 안드로...)
String _shortenCategory(String category) {
if (category.length <= 2) return category;
return category.substring(0, 2) + '...';
}
// 카테고리 툴팁 위젯 (UI만 담당, 축약 표기 적용)
Widget _buildCategoryWithTooltip(UnifiedEquipment equipment) {
final fullCategory = EquipmentDisplayHelper.formatCategory(
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));
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final maxContentWidth = screenWidth > 1200 ? 1200.0 : screenWidth - 32;
String screenTitle = '장비 목록';
if (widget.currentRoute == Routes.equipmentInList) {
screenTitle = '입고된 장비';
} else if (widget.currentRoute == Routes.equipmentOutList) {
screenTitle = '출고된 장비';
} else if (widget.currentRoute == Routes.equipmentRentList) {
screenTitle = '대여된 장비';
}
final int totalCount = _controller.equipments.length;
final List<UnifiedEquipment> filteredEquipments =
_appliedSearchKeyword.isEmpty
? _controller.equipments
: _controller.equipments.where((e) {
final keyword = _appliedSearchKeyword.toLowerCase();
// 모든 주요 필드에서 검색
return [
e.equipment.manufacturer,
e.equipment.name,
e.equipment.category,
e.equipment.subCategory,
e.equipment.subSubCategory,
e.equipment.serialNumber ?? '',
e.equipment.barcode ?? '',
e.equipment.remark ?? '',
e.equipment.warrantyLicense ?? '',
e.notes ?? '',
].any((field) => field.toLowerCase().contains(keyword));
}).toList();
final int filteredCount = filteredEquipments.length;
final int startIndex = (_currentPage - 1) * _pageSize;
final int endIndex =
(startIndex + _pageSize) > filteredCount
? filteredCount
: (startIndex + _pageSize);
final pagedEquipments = filteredEquipments.sublist(startIndex, endIndex);
// 선택된 장비 개수
final int selectedCount = _controller.getSelectedEquipmentCount();
final int selectedInCount = _controller.getSelectedInStockCount();
final int selectedOutCount = _controller.getSelectedEquipmentCountByStatus(
EquipmentStatus.out,
);
final int selectedRentCount = _controller.getSelectedEquipmentCountByStatus(
EquipmentStatus.rent,
);
return MainLayout(
title: screenTitle,
currentRoute: widget.currentRoute,
actions: [
IconButton(
icon: Icon(
_showDetailedColumns ? Icons.view_column : Icons.view_compact,
color: Colors.grey,
),
tooltip: _showDetailedColumns ? '간소화된 보기' : '상세 보기',
onPressed: () {
setState(() {
_showDetailedColumns = !_showDetailedColumns;
});
},
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
setState(() {
_controller.loadData();
_currentPage = 1;
});
},
color: Colors.grey,
),
],
child: Container(
width: maxContentWidth,
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(
screenTitle,
style: AppThemeTailwind.headingStyle,
),
),
if (selectedCount > 0)
Container(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(4),
),
child: Text(
'$selectedCount개 선택됨',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
if (widget.currentRoute == Routes.equipmentInList)
Row(
children: [
ElevatedButton.icon(
onPressed:
selectedInCount > 0 ? _handleOutEquipment : null,
icon: const Icon(
Icons.exit_to_app,
color: Colors.white,
),
label: const Text(
'출고',
style: TextStyle(color: Colors.white),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
disabledBackgroundColor: Colors.blue.withOpacity(0.5),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: () async {
final result = await Navigator.pushNamed(
context,
Routes.equipmentInAdd,
);
if (result == true) {
setState(() {
_controller.loadData();
_currentPage = 1;
});
}
},
icon: const Icon(Icons.add, color: Colors.white),
label: const Text(
'입고',
style: TextStyle(color: Colors.white),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 16),
SizedBox(
width: 220,
child: Row(
children: [
Expanded(
child: TextField(
decoration: const InputDecoration(
hintText: '장비 검색',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
isDense: true,
contentPadding: EdgeInsets.symmetric(
vertical: 8,
horizontal: 12,
),
),
onChanged: (value) {
setState(() {
_searchKeyword = value;
});
},
onSubmitted: (value) {
setState(() {
_appliedSearchKeyword = value;
_currentPage = 1;
});
},
),
),
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.arrow_forward),
tooltip: '검색',
onPressed: () {
setState(() {
_appliedSearchKeyword = _searchKeyword;
_currentPage = 1;
});
},
),
],
),
),
],
),
// 출고 목록 화면일 때 버튼들
if (widget.currentRoute == Routes.equipmentOutList)
Row(
children: [
ElevatedButton.icon(
onPressed:
selectedOutCount > 0
? () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('재입고 기능은 준비 중입니다.'),
),
);
}
: null,
icon: const Icon(
Icons.assignment_return,
color: Colors.white,
),
label: const Text(
'재입고',
style: TextStyle(color: Colors.white),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
disabledBackgroundColor: Colors.green.withOpacity(
0.5,
),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed:
selectedOutCount > 0
? () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('수리 요청 기능은 준비 중입니다.'),
),
);
}
: null,
icon: const Icon(Icons.build, color: Colors.white),
label: const Text(
'수리 요청',
style: TextStyle(color: Colors.white),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
disabledBackgroundColor: Colors.orange.withOpacity(
0.5,
),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
),
],
),
// 대여 목록 화면일 때 버튼들
if (widget.currentRoute == Routes.equipmentRentList)
Row(
children: [
ElevatedButton.icon(
onPressed:
selectedRentCount > 0
? () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('대여 반납 기능은 준비 중입니다.'),
),
);
}
: null,
icon: const Icon(
Icons.keyboard_return,
color: Colors.white,
),
label: const Text(
'반납',
style: TextStyle(color: Colors.white),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
disabledBackgroundColor: Colors.green.withOpacity(
0.5,
),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed:
selectedRentCount > 0
? () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('대여 연장 기능은 준비 중입니다.'),
),
);
}
: null,
icon: const Icon(Icons.date_range, color: Colors.white),
label: const Text(
'연장',
style: TextStyle(color: Colors.white),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
disabledBackgroundColor: Colors.blue.withOpacity(0.5),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
),
const SizedBox(height: 8),
Expanded(
child:
pagedEquipments.isEmpty
? const Center(child: Text('장비 정보가 없습니다.'))
: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(
minWidth: maxContentWidth,
),
child: EquipmentTable(
equipments: pagedEquipments,
selectedEquipmentIds:
_controller.selectedEquipmentIds,
showDetailedColumns: _showDetailedColumns,
onEquipmentSelected: _onEquipmentSelected,
getOutEquipmentInfo:
_controller.getOutEquipmentInfo,
buildCategoryWithTooltip: _buildCategoryWithTooltip,
// 수정 버튼 동작: 입고 폼(수정 모드)로 이동
onEdit: (id, status) async {
if (status == EquipmentStatus.in_) {
final result = await Navigator.pushNamed(
context,
Routes.equipmentInEdit,
arguments: id,
);
if (result == true) {
setState(() {
_controller.loadData();
});
}
} else {
// 출고/대여 등은 별도 폼으로 이동 필요시 구현
}
},
// 삭제 버튼 동작: 삭제 다이얼로그 및 삭제 처리
onDelete: (id, status) {
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: const Text('삭제 확인'),
content: const Text('이 장비 정보를 삭제하시겠습니까?'),
actions: [
TextButton(
onPressed:
() => Navigator.pop(context),
child: const Text('취소'),
),
TextButton(
onPressed: () {
setState(() {
// 입고/출고 상태에 따라 삭제 처리
if (status ==
EquipmentStatus.in_) {
MockDataService()
.deleteEquipmentIn(id);
} else if (status ==
EquipmentStatus.out) {
MockDataService()
.deleteEquipmentOut(id);
}
_controller.loadData();
});
Navigator.pop(context);
},
child: const Text('삭제'),
),
],
),
);
},
getSelectedInStockCount:
_controller.getSelectedInStockCount,
),
),
),
),
if (totalCount > _pageSize)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Pagination(
totalCount: filteredCount,
currentPage: _currentPage,
pageSize: _pageSize,
onPageChanged: (page) {
setState(() {
_currentPage = page;
});
},
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,805 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/models/address_model.dart';
import 'package:superport/screens/common/custom_widgets.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/screens/equipment/controllers/equipment_out_form_controller.dart';
import 'package:superport/screens/equipment/widgets/equipment_summary_card.dart';
import 'package:superport/screens/equipment/widgets/equipment_summary_row.dart';
import 'package:superport/screens/common/widgets/remark_input.dart';
class EquipmentOutFormScreen extends StatefulWidget {
final int? equipmentOutId;
final Equipment? selectedEquipment;
final int? selectedEquipmentInId;
final List<Map<String, dynamic>>? selectedEquipments;
const EquipmentOutFormScreen({
Key? key,
this.equipmentOutId,
this.selectedEquipment,
this.selectedEquipmentInId,
this.selectedEquipments,
}) : super(key: key);
@override
State<EquipmentOutFormScreen> createState() => _EquipmentOutFormScreenState();
}
class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
late final EquipmentOutFormController _controller;
@override
void initState() {
super.initState();
_controller = EquipmentOutFormController(dataService: MockDataService());
_controller.isEditMode = widget.equipmentOutId != null;
_controller.equipmentOutId = widget.equipmentOutId;
_controller.selectedEquipment = widget.selectedEquipment;
_controller.selectedEquipmentInId = widget.selectedEquipmentInId;
_controller.selectedEquipments = widget.selectedEquipments;
_controller.loadDropdownData();
if (_controller.isEditMode) {
// 수정 모드: 기존 출고 정보 로드
// (이 부분은 실제 서비스에서 컨트롤러에 메서드 추가 필요)
} else if (widget.selectedEquipments != null &&
widget.selectedEquipments!.isNotEmpty) {
// 다중 선택 장비 있음: 별도 초기화 필요시 컨트롤러에서 처리
} else if (widget.selectedEquipment != null) {
_controller.initializeWithSelectedEquipment(widget.selectedEquipment!);
}
}
// 요약 테이블 위젯 - 다중 선택 장비에 대한 요약 테이블
Widget _buildSummaryTable() {
if (_controller.selectedEquipments == null ||
_controller.selectedEquipments!.isEmpty) {
return const SizedBox.shrink();
}
// 각 장비별로 전체 폭을 사용하는 리스트로 구현
return Container(
width: double.infinity, // 전체 폭 사용
child: Card(
elevation: 2,
margin: EdgeInsets.zero, // margin 제거
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'선택된 장비 목록 (${_controller.selectedEquipments!.length}개)',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
// 리스트 헤더
Row(
children: const [
Expanded(
flex: 2,
child: Text(
'제조사',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
Expanded(
flex: 2,
child: Text(
'장비명',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
Expanded(
flex: 1,
child: Text(
'수량',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
Expanded(
flex: 2,
child: Text(
'워런티 시작일',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
Expanded(
flex: 2,
child: Text(
'워런티 종료일',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
const Divider(),
// 리스트 본문
Column(
children: List.generate(_controller.selectedEquipments!.length, (
index,
) {
final equipmentData = _controller.selectedEquipments![index];
final equipment = equipmentData['equipment'] as Equipment;
// 워런티 날짜를 임시로 저장할 수 있도록 상태를 관리(컨트롤러에 리스트로 추가하거나, 여기서 임시로 관리)
// 여기서는 equipment 객체의 필드를 직접 수정(실제 서비스에서는 별도 상태 관리 필요)
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
children: [
Expanded(flex: 2, child: Text(equipment.manufacturer)),
Expanded(flex: 2, child: Text(equipment.name)),
Expanded(flex: 1, child: Text('${equipment.quantity}')),
Expanded(
flex: 2,
child: InkWell(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate:
equipment.warrantyStartDate ??
DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) {
setState(() {
equipment.warrantyStartDate = picked;
});
}
},
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 4,
),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(4),
),
child: Text(
_formatDate(equipment.warrantyStartDate),
style: const TextStyle(
decoration: TextDecoration.underline,
color: Colors.blue,
),
),
),
),
),
Expanded(
flex: 2,
child: InkWell(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate:
equipment.warrantyEndDate ?? DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) {
setState(() {
equipment.warrantyEndDate = picked;
});
}
},
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 4,
),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(4),
),
child: Text(
_formatDate(equipment.warrantyEndDate),
style: const TextStyle(
decoration: TextDecoration.underline,
color: Colors.blue,
),
),
),
),
),
],
),
);
}),
),
],
),
),
),
);
}
// 날짜 포맷 유틸리티
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) {
// 담당자가 없거나 첫 번째 회사에 대한 담당자가 '없음'인 경우 등록 버튼 비활성화 조건
final bool canSubmit =
_controller.selectedCompanies.isNotEmpty &&
_controller.selectedCompanies[0] != null &&
_controller.hasManagersPerCompany[0] &&
_controller.filteredManagersPerCompany[0].first != '없음';
final int totalSelectedEquipments =
_controller.selectedEquipments?.length ?? 0;
return Scaffold(
appBar: AppBar(
title: Text(
_controller.isEditMode
? '장비 출고 수정'
: totalSelectedEquipments > 0
? '장비 출고 등록 (${totalSelectedEquipments}개)'
: '장비 출고 등록',
),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _controller.formKey,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 장비 정보 요약 섹션
if (_controller.selectedEquipments != null &&
_controller.selectedEquipments!.isNotEmpty)
_buildSummaryTable()
else if (_controller.selectedEquipment != null)
// 단일 장비 요약 카드도 전체 폭으로 맞춤
Container(
width: double.infinity,
child: EquipmentSingleSummaryCard(
equipment: _controller.selectedEquipment!,
),
)
else
const SizedBox.shrink(),
// 요약 카드 아래 라디오 버튼 추가
const SizedBox(height: 12),
// 전체 폭을 사용하는 라디오 버튼
Container(width: double.infinity, child: _buildOutTypeRadio()),
const SizedBox(height: 16),
// 출고 정보 입력 섹션 (수정/등록)
_buildOutgoingInfoSection(context),
// 비고 입력란 추가
const SizedBox(height: 16),
FormFieldWrapper(
label: '비고',
isRequired: false,
child: RemarkInput(
controller: _controller.remarkController,
hint: '비고를 입력하세요',
minLines: 4,
),
),
const SizedBox(height: 24),
// 담당자 없음 경고 메시지
if (_controller.selectedCompanies.isNotEmpty &&
_controller.selectedCompanies[0] != null &&
(!_controller.hasManagersPerCompany[0] ||
_controller.filteredManagersPerCompany[0].first ==
'없음'))
Container(
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.red.shade100,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.red.shade300),
),
child: const Row(
children: [
Icon(Icons.warning, color: Colors.red),
SizedBox(width: 8),
Expanded(
child: Text(
'선택한 회사에 등록된 담당자가 없습니다. 담당자를 먼저 등록해야 합니다.',
style: TextStyle(color: Colors.red),
),
),
],
),
),
// 저장 버튼
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed:
canSubmit
? () {
// 각 회사별 담당자를 첫 번째 항목으로 설정
for (
int i = 0;
i < _controller.selectedCompanies.length;
i++
) {
if (_controller.selectedCompanies[i] != null &&
_controller.hasManagersPerCompany[i] &&
_controller
.filteredManagersPerCompany[i]
.isNotEmpty &&
_controller
.filteredManagersPerCompany[i]
.first !=
'없음') {
_controller.selectedManagersPerCompany[i] =
_controller
.filteredManagersPerCompany[i]
.first;
}
}
_controller.saveEquipmentOut(
(msg) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(msg),
duration: const Duration(seconds: 2),
),
);
Navigator.pop(context, true);
},
(err) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(err),
duration: const Duration(seconds: 2),
),
);
},
);
}
: null,
style:
canSubmit
? AppThemeTailwind.primaryButtonStyle
: ElevatedButton.styleFrom(
backgroundColor: Colors.grey.shade300,
foregroundColor: Colors.grey.shade700,
),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Text(
_controller.isEditMode ? '수정하기' : '등록하기',
style: const TextStyle(fontSize: 16),
),
),
),
),
],
),
),
),
),
);
}
// 출고 정보 입력 섹션 위젯 (등록/수정 공통)
Widget _buildOutgoingInfoSection(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('출고 정보', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
// 출고일
_buildDateField(
context,
label: '출고일',
date: _controller.outDate,
onDateChanged: (picked) {
setState(() {
_controller.outDate = picked;
});
},
),
// 출고 회사 영역 헤더
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('출고 회사', style: TextStyle(fontWeight: FontWeight.bold)),
TextButton.icon(
onPressed: () {
setState(() {
_controller.addCompany();
});
},
icon: const Icon(Icons.add_circle_outline, size: 18),
label: const Text('출고 회사 추가'),
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
),
const SizedBox(height: 4),
// 동적 출고 회사 드롭다운 목록
...List.generate(_controller.selectedCompanies.length, (index) {
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: DropdownButtonFormField<String>(
value: _controller.selectedCompanies[index],
decoration: InputDecoration(
hintText: index == 0 ? '출고할 회사를 선택하세요' : '추가된 출고할 회사를 선택하세요',
// 이전 드롭다운에 값이 선택되지 않았으면 비활성화
enabled:
index == 0 ||
_controller.selectedCompanies[index - 1] != null,
),
items:
_controller.availableCompaniesPerDropdown[index]
.map(
(item) => DropdownMenuItem<String>(
value: item,
child: _buildCompanyDropdownItem(item),
),
)
.toList(),
validator: (value) {
if (index == 0 && (value == null || value.isEmpty)) {
return '출고 회사를 선택해주세요';
}
return null;
},
onChanged:
(index == 0 ||
_controller.selectedCompanies[index - 1] != null)
? (value) {
setState(() {
_controller.selectedCompanies[index] = value;
_controller.filterManagersByCompanyAtIndex(
value,
index,
);
_controller.updateAvailableCompanies();
});
}
: null,
),
);
}),
// 각 회사별 담당자 선택 목록
...List.generate(_controller.selectedCompanies.length, (index) {
// 회사가 선택된 경우에만 담당자 표시
if (_controller.selectedCompanies[index] != null) {
// 회사 정보 가져오기
final companyInfo = _controller.companiesWithBranches.firstWhere(
(info) => info.name == _controller.selectedCompanies[index],
orElse:
() => CompanyBranchInfo(
id: 0,
name: _controller.selectedCompanies[index]!,
originalName: _controller.selectedCompanies[index]!,
isMainCompany: true,
companyId: 0,
branchId: null,
),
);
// 실제 회사/지점 정보를 ID로 가져오기
Company? company;
Branch? branch;
if (companyInfo.companyId != null) {
company = _controller.dataService.getCompanyById(
companyInfo.companyId!,
);
if (!companyInfo.isMainCompany &&
companyInfo.branchId != null &&
company != null) {
final branches = company.branches;
if (branches != null) {
branch = branches.firstWhere(
(b) => b.id == companyInfo.branchId,
orElse:
() => Branch(
companyId: companyInfo.companyId!,
name: companyInfo.originalName,
),
);
}
}
}
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'담당자 정보 (${_controller.selectedCompanies[index]})',
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 15,
),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(4),
),
child:
company != null
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 본사/지점 정보 표시
if (companyInfo.isMainCompany &&
company.contactName != null &&
company.contactName!.isNotEmpty)
Text(
'${company.contactName} ${company.contactPosition ?? ""} ${company.contactPhone ?? ""} ${company.contactEmail ?? ""}',
style: AppThemeTailwind.bodyStyle,
),
if (!companyInfo.isMainCompany &&
branch != null &&
branch.contactName != null &&
branch.contactName!.isNotEmpty)
Text(
'${branch.contactName} ${branch.contactPosition ?? ""} ${branch.contactPhone ?? ""} ${branch.contactEmail ?? ""}',
style: AppThemeTailwind.bodyStyle,
),
const SizedBox(height: 8),
// 담당자 목록에서 실제 담당자 정보만 표시하는 부분은 제거
],
)
: Text(
'회사 정보를 불러올 수 없습니다.',
style: TextStyle(
color: Colors.red.shade400,
fontStyle: FontStyle.italic,
),
),
),
],
),
);
} else {
return const SizedBox.shrink();
}
}),
// 유지 보수(라이센스) 선택
_buildDropdownField(
label: '유지 보수', // 텍스트 변경
value: _controller.selectedLicense,
items: _controller.licenses,
hint: '유지 보수를 선택하세요', // 텍스트 변경
onChanged: (value) {
setState(() {
_controller.selectedLicense = value;
});
},
validator: (value) {
if (value == null || value.isEmpty) {
return '유지 보수를 선택해주세요'; // 텍스트 변경
}
return null;
},
),
],
);
}
// 날짜 선택 필드 위젯
Widget _buildDateField(
BuildContext context, {
required String label,
required DateTime date,
required ValueChanged<DateTime> onDateChanged,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
InkWell(
onTap: () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: date,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null && picked != date) {
onDateChanged(picked);
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 15),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_controller.formatDate(date),
style: AppThemeTailwind.bodyStyle,
),
const Icon(Icons.calendar_today, size: 20),
],
),
),
),
const SizedBox(height: 12),
],
);
}
// 드롭다운 필드 위젯
Widget _buildDropdownField({
required String label,
required String? value,
required List<String> items,
required String hint,
required ValueChanged<String?>? onChanged,
required String? Function(String?) validator,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
DropdownButtonFormField<String>(
value: value,
decoration: InputDecoration(hintText: hint),
items:
items
.map(
(item) => DropdownMenuItem<String>(
value: item,
child: Text(item),
),
)
.toList(),
validator: validator,
onChanged: onChanged,
),
const SizedBox(height: 12),
],
);
}
// 회사 이름을 표시하는 위젯 (지점 포함)
Widget _buildCompanyDropdownItem(String item) {
final TextStyle defaultStyle = TextStyle(
color: Colors.black87,
fontSize: 14,
fontWeight: FontWeight.normal,
);
// 컨트롤러에서 해당 항목에 대한 정보 확인
final companyInfoList =
_controller.companiesWithBranches
.where((info) => info.name == item)
.toList();
// 회사 정보가 존재하고 지점인 경우
if (companyInfoList.isNotEmpty && !companyInfoList[0].isMainCompany) {
final companyInfo = companyInfoList[0];
final parentCompanyName = companyInfo.parentCompanyName ?? '';
final branchName = companyInfo.displayName ?? companyInfo.originalName;
// Row 대신 RichText 사용 - 지점 표시
return RichText(
text: TextSpan(
style: defaultStyle, // 기본 스타일 설정
children: [
WidgetSpan(
child: Icon(
Icons.subdirectory_arrow_right,
size: 16,
color: Colors.grey,
),
alignment: PlaceholderAlignment.middle,
),
TextSpan(text: ' ', style: defaultStyle),
TextSpan(
text: parentCompanyName, // 회사명
style: defaultStyle,
),
TextSpan(text: ' ', style: defaultStyle),
TextSpan(
text: branchName, // 지점명
style: const TextStyle(
color: Colors.indigo,
fontWeight: FontWeight.w500,
fontSize: 14,
),
),
],
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
);
}
// 일반 회사명 (본사)
return RichText(
text: TextSpan(
style: defaultStyle, // 기본 스타일 설정
children: [
WidgetSpan(
child: Icon(Icons.business, size: 16, color: Colors.black54),
alignment: PlaceholderAlignment.middle,
),
TextSpan(text: ' ', style: defaultStyle),
TextSpan(
text: item,
style: defaultStyle.copyWith(fontWeight: FontWeight.w500),
),
],
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
);
}
// 회사 ID에 따른 담당자 정보를 가져와 표시하는 위젯 목록 생성
List<Widget> _getUsersForCompany(CompanyBranchInfo companyInfo) {
final List<Widget> userWidgets = [];
// 판교지점 특별 처리
if (companyInfo.originalName == "판교지점" &&
companyInfo.parentCompanyName == "LG전자") {
userWidgets.add(
Text(
'정수진 사원 010-4567-8901 jung.soojin@lg.com',
style: AppThemeTailwind.bodyStyle,
),
);
}
return userWidgets;
}
// 출고/대여/폐기 라디오 버튼 위젯
Widget _buildOutTypeRadio() {
// 출고 유형 리스트
final List<String> outTypes = ['출고', '대여', '폐기'];
return Row(
mainAxisAlignment: MainAxisAlignment.start,
children:
outTypes.map((type) {
return Row(
children: [
Radio<String>(
value: type,
groupValue: _controller.outType, // 컨트롤러에서 현재 선택값 관리
onChanged: (value) {
setState(() {
_controller.outType = value!;
});
},
),
Text(type),
],
);
}).toList(),
);
}
}

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

View File

@@ -0,0 +1,405 @@
import 'package:flutter/material.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/screens/common/main_layout.dart';
import 'package:superport/screens/common/custom_widgets.dart';
import 'package:superport/screens/common/widgets/pagination.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
import 'package:superport/screens/common/widgets/category_autocomplete_field.dart';
/// 물품 관리(등록) 화면
/// 이름, 제조사, 대분류, 중분류, 소분류만 등록/조회 가능
class GoodsListScreen extends StatefulWidget {
const GoodsListScreen({super.key});
@override
State<GoodsListScreen> createState() => _GoodsListScreenState();
}
class _GoodsListScreenState extends State<GoodsListScreen> {
final MockDataService _dataService = MockDataService();
late List<_GoodsItem> _goodsList;
int _currentPage = 1;
final int _pageSize = 10;
@override
void initState() {
super.initState();
_loadGoods();
}
void _loadGoods() {
final allEquipments = _dataService.getAllEquipmentIns();
final goodsSet = <String, _GoodsItem>{};
for (final equipmentIn in allEquipments) {
final eq = equipmentIn.equipment;
final key =
'${eq.manufacturer}|${eq.name}|${eq.category}|${eq.subCategory}|${eq.subSubCategory}';
goodsSet[key] = _GoodsItem(
name: eq.name,
manufacturer: eq.manufacturer,
category: eq.category,
subCategory: eq.subCategory,
subSubCategory: eq.subSubCategory,
);
}
setState(() {
_goodsList = goodsSet.values.toList();
});
}
void _showAddGoodsDialog() async {
final result = await showDialog<_GoodsItem>(
context: context,
builder: (context) => _GoodsFormDialog(),
);
if (result != null) {
setState(() {
_goodsList.add(result);
});
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('물품이 등록되었습니다.')));
}
}
void _showEditGoodsDialog(int index) async {
final result = await showDialog<_GoodsItem>(
context: context,
builder: (context) => _GoodsFormDialog(item: _goodsList[index]),
);
if (result != null) {
setState(() {
_goodsList[index] = result;
});
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('물품 정보가 수정되었습니다.')));
}
}
void _deleteGoods(int index) {
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: const Text('삭제 확인'),
content: const Text('이 물품 정보를 삭제하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소'),
),
TextButton(
onPressed: () {
setState(() {
_goodsList.removeAt(index);
});
Navigator.pop(context);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('물품이 삭제되었습니다.')));
},
child: const Text('삭제'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final maxContentWidth = screenWidth > 1200 ? 1200.0 : screenWidth - 32;
final int totalCount = _goodsList.length;
final int startIndex = (_currentPage - 1) * _pageSize;
final int endIndex =
(startIndex + _pageSize) > totalCount
? totalCount
: (startIndex + _pageSize);
final pagedGoods = _goodsList.sublist(startIndex, endIndex);
return MainLayout(
title: '물품 관리',
currentRoute: Routes.goods,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadGoods,
color: Colors.grey,
),
],
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageTitle(
title: '물품 목록',
width: maxContentWidth - 32,
rightWidget: ElevatedButton.icon(
onPressed: _showAddGoodsDialog,
icon: const Icon(Icons.add),
label: const Text('추가'),
style: AppThemeTailwind.primaryButtonStyle,
),
),
Expanded(
child: DataTableCard(
width: maxContentWidth - 32,
child:
pagedGoods.isEmpty
? const Center(child: Text('등록된 물품이 없습니다.'))
: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Container(
constraints: BoxConstraints(
minWidth: maxContentWidth - 64,
),
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: DataTable(
columns: const [
DataColumn(label: Text('번호')),
DataColumn(label: Text('이름')),
DataColumn(label: Text('제조사')),
DataColumn(label: Text('대분류')),
DataColumn(label: Text('중분류')),
DataColumn(label: Text('소분류')),
DataColumn(label: Text('관리')),
],
rows: List.generate(pagedGoods.length, (i) {
final item = pagedGoods[i];
final realIndex = startIndex + i;
return DataRow(
cells: [
DataCell(Text('${realIndex + 1}')),
DataCell(Text(item.name)),
DataCell(Text(item.manufacturer)),
DataCell(Text(item.category)),
DataCell(Text(item.subCategory)),
DataCell(Text(item.subSubCategory)),
DataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(
Icons.edit,
color: AppThemeTailwind.primary,
),
onPressed:
() => _showEditGoodsDialog(
realIndex,
),
),
IconButton(
icon: const Icon(
Icons.delete,
color: AppThemeTailwind.danger,
),
onPressed:
() => _deleteGoods(realIndex),
),
],
),
),
],
);
}),
),
),
),
),
),
),
if (totalCount > _pageSize)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Pagination(
totalCount: totalCount,
currentPage: _currentPage,
pageSize: _pageSize,
onPageChanged: (page) {
setState(() {
_currentPage = page;
});
},
),
),
],
),
),
);
}
}
/// 물품 데이터 모델 (이름, 제조사, 대중소분류)
class _GoodsItem {
final String name;
final String manufacturer;
final String category;
final String subCategory;
final String subSubCategory;
_GoodsItem({
required this.name,
required this.manufacturer,
required this.category,
required this.subCategory,
required this.subSubCategory,
});
}
/// 물품 등록/수정 폼 다이얼로그
class _GoodsFormDialog extends StatefulWidget {
final _GoodsItem? item;
const _GoodsFormDialog({this.item});
@override
State<_GoodsFormDialog> createState() => _GoodsFormDialogState();
}
class _GoodsFormDialogState extends State<_GoodsFormDialog> {
final _formKey = GlobalKey<FormState>();
late String _name;
late String _manufacturer;
late String _category;
late String _subCategory;
late String _subSubCategory;
late final MockDataService _dataService;
late final List<String> _manufacturerList;
late final List<String> _nameList;
late final List<String> _categoryList;
late final List<String> _subCategoryList;
late final List<String> _subSubCategoryList;
@override
void initState() {
super.initState();
_name = widget.item?.name ?? '';
_manufacturer = widget.item?.manufacturer ?? '';
_category = widget.item?.category ?? '';
_subCategory = widget.item?.subCategory ?? '';
_subSubCategory = widget.item?.subSubCategory ?? '';
_dataService = MockDataService();
_manufacturerList = _dataService.getAllManufacturers();
_nameList = _dataService.getAllEquipmentNames();
_categoryList = _dataService.getAllCategories();
_subCategoryList = _dataService.getAllSubCategories();
_subSubCategoryList = _dataService.getAllSubSubCategories();
}
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.item == null ? '신상품 등록' : '신상품 정보 수정',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
FormFieldWrapper(
label: '이름',
isRequired: true,
child: CategoryAutocompleteField(
hintText: '이름을 입력 또는 선택하세요',
value: _name,
items: _nameList,
isRequired: true,
onSelect: (v) => setState(() => _name = v),
),
),
FormFieldWrapper(
label: '제조사',
isRequired: true,
child: CategoryAutocompleteField(
hintText: '제조사를 입력 또는 선택하세요',
value: _manufacturer,
items: _manufacturerList,
isRequired: true,
onSelect: (v) => setState(() => _manufacturer = v),
),
),
FormFieldWrapper(
label: '대분류',
isRequired: true,
child: CategoryAutocompleteField(
hintText: '대분류를 입력 또는 선택하세요',
value: _category,
items: _categoryList,
isRequired: true,
onSelect: (v) => setState(() => _category = v),
),
),
FormFieldWrapper(
label: '중분류',
isRequired: true,
child: CategoryAutocompleteField(
hintText: '중분류를 입력 또는 선택하세요',
value: _subCategory,
items: _subCategoryList,
isRequired: true,
onSelect: (v) => setState(() => _subCategory = v),
),
),
FormFieldWrapper(
label: '소분류',
isRequired: true,
child: CategoryAutocompleteField(
hintText: '소분류를 입력 또는 선택하세요',
value: _subSubCategory,
items: _subSubCategoryList,
isRequired: true,
onSelect: (v) => setState(() => _subSubCategory = v),
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('취소'),
),
const SizedBox(width: 8),
ElevatedButton(
style: AppThemeTailwind.primaryButtonStyle,
onPressed: () {
if (_formKey.currentState?.validate() ?? false) {
Navigator.of(context).pop(
_GoodsItem(
name: _name,
manufacturer: _manufacturer,
category: _category,
subCategory: _subCategory,
subSubCategory: _subSubCategory,
),
);
}
},
child: Text(widget.item == null ? '등록' : '수정'),
),
],
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:superport/models/license_model.dart';
import 'package:superport/services/mock_data_service.dart';
// 라이센스 폼의 상태 및 비즈니스 로직을 담당하는 컨트롤러
class LicenseFormController {
final MockDataService dataService;
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
bool isEditMode = false;
int? licenseId;
String name = '';
int durationMonths = 12; // 기본값: 12개월
String visitCycle = '미방문'; // 기본값: 미방문
LicenseFormController({required this.dataService, this.licenseId});
// 라이센스 정보 로드 (수정 모드)
void loadLicense() {
if (licenseId == null) return;
final license = dataService.getLicenseById(licenseId!);
if (license != null) {
name = license.name;
durationMonths = license.durationMonths;
visitCycle = license.visitCycle;
}
}
// 라이센스 저장 (UI에서 호출)
void saveLicense(Function() onSuccess) {
if (formKey.currentState?.validate() != true) return;
formKey.currentState?.save();
if (isEditMode && licenseId != null) {
final license = dataService.getLicenseById(licenseId!);
if (license != null) {
final updatedLicense = License(
id: license.id,
companyId: license.companyId,
name: name,
durationMonths: durationMonths,
visitCycle: visitCycle,
);
dataService.updateLicense(updatedLicense);
}
} else {
// 라이센스 추가 시 임시 회사 ID 사용 또는 나중에 설정하도록 변경
final newLicense = License(
companyId: 1, // 기본값 또는 필요에 따라 수정
name: name,
durationMonths: durationMonths,
visitCycle: visitCycle,
);
dataService.addLicense(newLicense);
}
onSuccess();
}
}

View File

@@ -0,0 +1,21 @@
import 'package:superport/models/license_model.dart';
import 'package:superport/services/mock_data_service.dart';
// 라이센스 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러
class LicenseListController {
final MockDataService dataService;
List<License> licenses = [];
LicenseListController({required this.dataService});
// 데이터 로드
void loadData() {
licenses = dataService.getAllLicenses();
}
// 라이센스 삭제
void deleteLicense(int id) {
dataService.deleteLicense(id);
loadData();
}
}

View File

@@ -0,0 +1,262 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:superport/models/license_model.dart';
import 'package:superport/screens/license/controllers/license_form_controller.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
import 'package:superport/screens/common/custom_widgets.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/utils/validators.dart';
// 유지보수 등록/수정 화면 (UI만 담당, 상태/로직 분리)
class MaintenanceFormScreen extends StatefulWidget {
final int? maintenanceId;
const MaintenanceFormScreen({Key? key, this.maintenanceId}) : super(key: key);
@override
_MaintenanceFormScreenState createState() => _MaintenanceFormScreenState();
}
class _MaintenanceFormScreenState extends State<MaintenanceFormScreen> {
late final LicenseFormController _controller;
// 방문주기 드롭다운 옵션
final List<String> _visitCycleOptions = [
'미방문',
'장애시 지원',
'',
'격월',
'분기',
'반기',
'',
];
// 점검형태 라디오 옵션
final List<String> _inspectionTypeOptions = ['방문', '원격'];
String _selectedVisitCycle = '미방문';
String _selectedInspectionType = '방문';
int _durationMonths = 12;
@override
void initState() {
super.initState();
_controller = LicenseFormController(
dataService: MockDataService(),
licenseId: widget.maintenanceId,
);
_controller.isEditMode = widget.maintenanceId != null;
if (_controller.isEditMode) {
_controller.loadLicense();
// TODO: 기존 데이터 로딩 시 _selectedVisitCycle, _selectedInspectionType, _durationMonths 값 세팅 필요
}
}
@override
Widget build(BuildContext context) {
// 유지보수 명은 유지보수기간, 방문주기, 점검형태를 결합해서 표기
final String maintenanceName =
'${_durationMonths}개월,${_selectedVisitCycle},${_selectedInspectionType}';
return Scaffold(
appBar: AppBar(
title: Text(_controller.isEditMode ? '유지보수 수정' : '유지보수 등록'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _controller.formKey,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 유지보수 명 표기 (입력 불가, 자동 생성)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'유지보수 명',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 8,
),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(4),
color: Colors.grey.shade100,
),
child: Text(
maintenanceName,
style: const TextStyle(fontSize: 16),
),
),
],
),
),
// 유지보수 기간 (개월)
_buildTextField(
label: '유지보수 기간 (개월)',
initialValue: _durationMonths.toString(),
hintText: '유지보수 기간을 입력하세요',
suffixText: '개월',
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
validator: (value) => validateNumber(value, '유지보수 기간'),
onChanged: (value) {
setState(() {
_durationMonths = int.tryParse(value ?? '') ?? 0;
});
},
),
// 방문 주기 (드롭다운)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'방문 주기',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
DropdownButtonFormField<String>(
value: _selectedVisitCycle,
items:
_visitCycleOptions
.map(
(option) => DropdownMenuItem(
value: option,
child: Text(option),
),
)
.toList(),
onChanged: (value) {
setState(() {
_selectedVisitCycle = value!;
});
},
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(
horizontal: 8,
vertical: 0,
),
),
validator:
(value) =>
value == null || value.isEmpty
? '방문 주기를 선택하세요'
: null,
),
],
),
),
// 점검 형태 (라디오버튼)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'점검 형태',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Row(
children:
_inspectionTypeOptions.map((type) {
return Row(
children: [
Radio<String>(
value: type,
groupValue: _selectedInspectionType,
onChanged: (value) {
setState(() {
_selectedInspectionType = value!;
});
},
),
Text(type),
],
);
}).toList(),
),
],
),
),
const SizedBox(height: 24),
// 저장 버튼
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
if (_controller.formKey.currentState!.validate()) {
_controller.formKey.currentState!.save();
// 유지보수 명 결합하여 저장
final String saveName =
'${_durationMonths}개월,${_selectedVisitCycle},${_selectedInspectionType}';
_controller.name = saveName;
_controller.durationMonths = _durationMonths;
_controller.visitCycle = _selectedVisitCycle;
// 점검형태 저장 로직 필요 시 추가
setState(() {
_controller.saveLicense(() {
Navigator.pop(context, true);
});
});
}
},
style: AppThemeTailwind.primaryButtonStyle,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Text(
_controller.isEditMode ? '수정하기' : '등록하기',
style: const TextStyle(fontSize: 16),
),
),
),
),
],
),
),
),
),
);
}
// 공통 텍스트 필드 위젯 (onSaved → onChanged로 변경)
Widget _buildTextField({
required String label,
required String initialValue,
required String hintText,
String? suffixText,
TextInputType? keyboardType,
List<TextInputFormatter>? inputFormatters,
required String? Function(String?) validator,
required void Function(String?) onChanged,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
TextFormField(
initialValue: initialValue,
decoration: InputDecoration(
hintText: hintText,
suffixText: suffixText,
),
keyboardType: keyboardType,
inputFormatters: inputFormatters,
validator: validator,
onChanged: onChanged,
),
],
),
);
}
}

View File

@@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
import 'package:superport/models/license_model.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
import 'package:superport/screens/common/main_layout.dart';
import 'package:superport/screens/common/custom_widgets.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/screens/license/controllers/license_list_controller.dart';
import 'package:superport/screens/license/widgets/license_table.dart';
import 'package:superport/screens/common/widgets/pagination.dart';
// 유지보수 목록 화면 (UI만 담당, 상태/로직/테이블 분리)
class MaintenanceListScreen extends StatefulWidget {
const MaintenanceListScreen({super.key});
@override
State<MaintenanceListScreen> createState() => _MaintenanceListScreenState();
}
// 유지보수 목록 화면의 상태 클래스
class _MaintenanceListScreenState extends State<MaintenanceListScreen> {
late final LicenseListController _controller;
// 페이지네이션 상태 추가
int _currentPage = 1;
final int _pageSize = 10;
@override
void initState() {
super.initState();
_controller = LicenseListController(dataService: MockDataService());
_controller.loadData();
}
void _reload() {
setState(() {
_controller.loadData();
});
}
void _navigateToAddScreen() async {
final result = await Navigator.pushNamed(context, '/license/add');
if (result == true) {
_reload();
}
}
void _navigateToEditScreen(int id) async {
final result = await Navigator.pushNamed(
context,
'/license/edit',
arguments: id,
);
if (result == true) {
_reload();
}
}
void _deleteLicense(int id) {
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: const Text('삭제 확인'),
content: const Text('이 라이센스 정보를 삭제하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소'),
),
TextButton(
onPressed: () {
setState(() {
_controller.deleteLicense(id);
});
Navigator.pop(context);
},
child: const Text('삭제'),
),
],
),
);
}
// 회사명 반환 함수 (재사용성 위해 분리)
String _getCompanyName(int companyId) {
return MockDataService().getCompanyById(companyId)?.name ?? '-';
}
@override
Widget build(BuildContext context) {
// 대시보드 폭에 맞게 조정
final screenWidth = MediaQuery.of(context).size.width;
final maxContentWidth = screenWidth > 1200 ? 1200.0 : screenWidth - 32;
// 페이지네이션 데이터 슬라이싱
final int totalCount = _controller.licenses.length;
final int startIndex = (_currentPage - 1) * _pageSize;
final int endIndex =
(startIndex + _pageSize) > totalCount
? totalCount
: (startIndex + _pageSize);
final pagedLicenses = _controller.licenses.sublist(startIndex, endIndex);
return MainLayout(
title: '유지보수 관리',
currentRoute: Routes.license,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _reload,
color: Colors.grey,
),
],
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageTitle(
title: '유지보수 목록',
width: maxContentWidth - 32,
rightWidget: ElevatedButton.icon(
onPressed: _navigateToAddScreen,
icon: const Icon(Icons.add),
label: const Text('추가'),
style: AppThemeTailwind.primaryButtonStyle,
),
),
Expanded(
child: DataTableCard(
width: maxContentWidth - 32,
child:
pagedLicenses.isEmpty
? const Center(child: Text('등록된 라이센스 정보가 없습니다.'))
: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Container(
constraints: BoxConstraints(
minWidth: maxContentWidth - 64,
),
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: LicenseTable(
licenses: pagedLicenses,
getCompanyName: _getCompanyName,
onEdit: _navigateToEditScreen,
onDelete: _deleteLicense,
),
),
),
),
),
),
// 페이지네이션 위젯 추가
if (totalCount > _pageSize)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Pagination(
totalCount: totalCount,
currentPage: _currentPage,
pageSize: _pageSize,
onPageChanged: (page) {
setState(() {
_currentPage = page;
});
},
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:superport/models/license_model.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
// 라이센스 목록 테이블 위젯 (SRP, 재사용성)
class LicenseTable extends StatelessWidget {
final List<License> licenses;
final String Function(int companyId) getCompanyName;
final void Function(int id) onEdit;
final void Function(int id) onDelete;
const LicenseTable({
super.key,
required this.licenses,
required this.getCompanyName,
required this.onEdit,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
return DataTable(
columns: const [
DataColumn(label: Text('번호')),
DataColumn(label: Text('유지보수명')),
DataColumn(label: Text('기간')),
DataColumn(label: Text('방문주기')),
DataColumn(label: Text('점검형태')),
DataColumn(label: Text('관리')),
],
rows:
licenses.map((license) {
// name에서 기간, 방문주기, 점검형태 파싱 (예: '12개월,격월,방문')
final parts = license.name.split(',');
final period = parts.isNotEmpty ? parts[0] : '-';
final visit = parts.length > 1 ? parts[1] : '-';
final inspection = parts.length > 2 ? parts[2] : '-';
return DataRow(
cells: [
DataCell(Text('${license.id}')),
DataCell(Text(license.name)),
DataCell(Text(period)),
DataCell(Text(visit)),
DataCell(Text(inspection)),
DataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(
Icons.edit,
color: AppThemeTailwind.primary,
),
onPressed: () => onEdit(license.id!),
),
IconButton(
icon: const Icon(
Icons.delete,
color: AppThemeTailwind.danger,
),
onPressed: () => onDelete(license.id!),
),
],
),
),
],
);
}).toList(),
);
}
}

View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
/// 로그인 화면의 상태 및 비즈니스 로직을 담당하는 ChangeNotifier 기반 컨트롤러
class LoginController extends ChangeNotifier {
/// 아이디 입력 컨트롤러
final TextEditingController idController = TextEditingController();
/// 비밀번호 입력 컨트롤러
final TextEditingController pwController = TextEditingController();
/// 아이디 입력란 포커스
final FocusNode idFocus = FocusNode();
/// 비밀번호 입력란 포커스
final FocusNode pwFocus = FocusNode();
/// 아이디 저장 여부
bool saveId = false;
/// 아이디 저장 체크박스 상태 변경
void setSaveId(bool value) {
saveId = value;
notifyListeners();
}
/// 로그인 처리 (샘플)
bool login() {
// 실제 인증 로직은 구현하지 않음
// 항상 true 반환 (샘플)
return true;
}
@override
void dispose() {
idController.dispose();
pwController.dispose();
idFocus.dispose();
pwFocus.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/login/controllers/login_controller.dart';
import 'package:superport/screens/login/widgets/login_view.dart';
/// 로그인 화면 진입점 (상태/로직은 controller, UI는 LoginView 위젯에 위임)
class LoginScreen extends StatefulWidget {
const LoginScreen({Key? key}) : super(key: key);
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
late final LoginController _controller;
@override
void initState() {
super.initState();
// 로그인 컨트롤러 초기화 (필요시 DI 적용)
_controller = LoginController();
}
// 로그인 성공 시 콜백 (예: overview로 이동)
void _onLoginSuccess() {
Navigator.of(context).pushReplacementNamed('/home');
}
@override
Widget build(BuildContext context) {
return LoginView(controller: _controller, onLoginSuccess: _onLoginSuccess);
}
}

View File

@@ -0,0 +1,301 @@
import 'package:flutter/material.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
import 'dart:math' as math;
import 'package:wave/wave.dart';
import 'package:wave/config.dart';
import 'package:superport/screens/login/controllers/login_controller.dart';
import 'package:provider/provider.dart';
/// 로그인 화면 진입점 위젯 (controller를 ChangeNotifierProvider로 주입)
class LoginView extends StatelessWidget {
final LoginController controller;
final VoidCallback onLoginSuccess;
const LoginView({
Key? key,
required this.controller,
required this.onLoginSuccess,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider<LoginController>.value(
value: controller,
child: const _LoginViewBody(),
);
}
}
/// 로그인 화면 전체 레이아웃 및 애니메이션 배경
class _LoginViewBody extends StatelessWidget {
const _LoginViewBody({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
// wave 패키지로 wavy liquid 애니메이션 배경 적용
Positioned.fill(
child: WaveWidget(
config: CustomConfig(
gradients: [
[Color(0xFFF7FAFC), Color(0xFFB6E0FE)],
[Color(0xFFB6E0FE), Color(0xFF3182CE)],
[Color(0xFF3182CE), Color(0xFF243B53)],
],
durations: [4200, 5000, 7000],
heightPercentages: [0.18, 0.25, 0.38],
blur: const MaskFilter.blur(BlurStyle.solid, 8),
gradientBegin: Alignment.topLeft,
gradientEnd: Alignment.bottomRight,
),
waveAmplitude: 18,
size: Size.infinite,
),
),
Center(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32.0),
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 40,
horizontal: 32,
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.95),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 32,
offset: const Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: const [
AnimatedBoatIcon(),
SizedBox(height: 32),
Text('supERPort', style: AppThemeTailwind.headingStyle),
SizedBox(height: 24),
LoginForm(),
SizedBox(height: 16),
SaveIdCheckbox(),
SizedBox(height: 32),
LoginButton(),
SizedBox(height: 48),
],
),
),
),
),
),
// 카피라이트를 화면 중앙 하단에 고정
Positioned(
left: 0,
right: 0,
bottom: 32,
child: Center(
child: Opacity(
opacity: 0.7,
child: Text(
'Copyright 2025 CClabs. All rights reserved.',
style: AppThemeTailwind.smallText.copyWith(fontSize: 13),
),
),
),
),
],
),
);
}
}
/// 요트 아이콘 애니메이션 위젯
class AnimatedBoatIcon extends StatefulWidget {
final Color color;
final double size;
const AnimatedBoatIcon({
Key? key,
this.color = const Color(0xFF3182CE),
this.size = 80,
}) : super(key: key);
@override
State<AnimatedBoatIcon> createState() => _AnimatedBoatIconState();
}
class _AnimatedBoatIconState extends State<AnimatedBoatIcon>
with TickerProviderStateMixin {
late AnimationController _boatGrowController;
late Animation<double> _boatScaleAnim;
late AnimationController _boatFloatController;
late Animation<double> _boatFloatAnim;
@override
void initState() {
super.initState();
_boatGrowController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1100),
);
_boatScaleAnim = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _boatGrowController, curve: Curves.elasticOut),
);
_boatFloatController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1800),
);
_boatFloatAnim = Tween<double>(begin: -0.08, end: 0.08).animate(
CurvedAnimation(parent: _boatFloatController, curve: Curves.easeInOut),
);
_boatGrowController.forward().then((_) {
_boatFloatController.repeat(reverse: true);
});
}
@override
void dispose() {
_boatGrowController.dispose();
_boatFloatController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: Listenable.merge([_boatGrowController, _boatFloatController]),
builder: (context, child) {
final double scale = _boatScaleAnim.value;
final double angle =
(_boatGrowController.isCompleted) ? _boatFloatAnim.value : 0.0;
return Transform.translate(
offset: Offset(
(_boatGrowController.isCompleted) ? math.sin(angle) * 8 : 0,
0,
),
child: Transform.rotate(
angle: angle,
child: Transform.scale(scale: scale, child: child),
),
);
},
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: widget.color.withOpacity(0.18),
blurRadius: widget.size * 0.3,
offset: Offset(0, widget.size * 0.1),
),
],
),
child: Icon(
Icons.directions_boat,
size: widget.size,
color: widget.color,
),
),
);
}
}
/// 로그인 입력 폼 위젯 (ID, PW)
class LoginForm extends StatelessWidget {
const LoginForm({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final controller = Provider.of<LoginController>(context);
return Column(
children: [
TextField(
controller: controller.idController,
focusNode: controller.idFocus,
decoration: const InputDecoration(
labelText: 'ID',
border: OutlineInputBorder(),
),
style: AppThemeTailwind.bodyStyle,
textInputAction: TextInputAction.next,
onSubmitted: (_) {
FocusScope.of(context).requestFocus(controller.pwFocus);
},
),
const SizedBox(height: 16),
TextField(
controller: controller.pwController,
focusNode: controller.pwFocus,
decoration: const InputDecoration(
labelText: 'PW',
border: OutlineInputBorder(),
),
style: AppThemeTailwind.bodyStyle,
obscureText: true,
textInputAction: TextInputAction.done,
onSubmitted: (_) {
// 엔터 시 로그인 버튼에 포커스 이동 또는 로그인 시도 가능
},
),
],
);
}
}
/// 아이디 저장 체크박스 위젯
class SaveIdCheckbox extends StatelessWidget {
const SaveIdCheckbox({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final controller = Provider.of<LoginController>(context);
return Row(
children: [
Checkbox(
value: controller.saveId,
onChanged: (bool? value) {
controller.setSaveId(value ?? false);
},
),
Text('아이디 저장', style: AppThemeTailwind.bodyStyle),
],
);
}
}
/// 로그인 버튼 위젯
class LoginButton extends StatelessWidget {
const LoginButton({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final controller = Provider.of<LoginController>(context, listen: false);
final onLoginSuccess =
(context.findAncestorWidgetOfExactType<LoginView>() as LoginView)
.onLoginSuccess;
return SizedBox(
width: double.infinity,
child: ElevatedButton(
style: AppThemeTailwind.primaryButtonStyle.copyWith(
elevation: MaterialStateProperty.all(4),
shadowColor: MaterialStateProperty.all(
const Color(0xFF3182CE).withOpacity(0.18),
),
),
onPressed: () async {
final bool result = controller.login();
if (!result) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('로그인에 실패했습니다.')));
return;
}
// 로그인 성공 시 애니메이션 등은 필요시 별도 처리
onLoginSuccess();
},
child: const Text('로그인'),
),
);
}
}

View File

@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
// 대시보드(Overview) 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러
class OverviewController {
final MockDataService dataService;
int totalCompanies = 0;
int totalUsers = 0;
int totalEquipmentIn = 0;
int totalEquipmentOut = 0;
int totalLicenses = 0;
// 최근 활동 데이터
List<Map<String, dynamic>> recentActivities = [];
OverviewController({required this.dataService});
// 데이터 로드 및 통계 계산
void loadData() {
totalCompanies = dataService.getAllCompanies().length;
totalUsers = dataService.getAllUsers().length;
// 실제 서비스에서는 아래 메서드 구현 필요
totalEquipmentIn = 32; // 임시 데이터
totalEquipmentOut = 18; // 임시 데이터
totalLicenses = dataService.getAllLicenses().length;
_loadRecentActivities();
}
// 최근 활동 데이터 로드 (임시 데이터)
void _loadRecentActivities() {
recentActivities = [
{
'type': '장비 입고',
'title': '라우터 입고 처리 완료',
'time': '30분 전',
'user': '홍길동',
'icon': Icons.input,
'color': AppThemeTailwind.success,
},
{
'type': '사용자 추가',
'title': '새 관리자 등록',
'time': '1시간 전',
'user': '김철수',
'icon': Icons.person_add,
'color': AppThemeTailwind.primary,
},
{
'type': '장비 출고',
'title': '모니터 5대 출고 처리',
'time': '2시간 전',
'user': '이영희',
'icon': Icons.output,
'color': AppThemeTailwind.warning,
},
];
}
}

View File

@@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/common/layout_components.dart';
import 'package:superport/screens/common/main_layout.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/screens/overview/controllers/overview_controller.dart';
import 'package:superport/screens/overview/widgets/stats_grid.dart';
import 'package:superport/screens/overview/widgets/recent_activities_list.dart';
// 대시보드(Overview) 화면 (UI만 담당, 상태/로직/위젯 분리)
class OverviewScreen extends StatefulWidget {
const OverviewScreen({Key? key}) : super(key: key);
@override
_OverviewScreenState createState() => _OverviewScreenState();
}
class _OverviewScreenState extends State<OverviewScreen> {
late final OverviewController _controller;
@override
void initState() {
super.initState();
_controller = OverviewController(dataService: MockDataService());
_controller.loadData();
}
void _reload() {
setState(() {
_controller.loadData();
});
}
@override
Widget build(BuildContext context) {
// 전체 배경색을 회색(AppThemeTailwind.surface)으로 지정
return Container(
color: AppThemeTailwind.surface, // 회색 배경
child: MainLayout(
title: '', // 타이틀 없음
currentRoute: Routes.home,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _reload,
color: AppThemeTailwind.muted,
),
IconButton(
icon: const Icon(Icons.notifications_none),
onPressed: () {},
color: AppThemeTailwind.muted,
),
IconButton(
icon: const Icon(Icons.logout),
tooltip: '로그아웃',
onPressed: () {
Navigator.of(context).pushReplacementNamed('/login');
},
color: AppThemeTailwind.muted,
),
],
child: SingleChildScrollView(
padding: EdgeInsets.zero, // 여백 0
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 상단 경로 표기 완전 삭제
// 하단부 전체를 감싸는 라운드 흰색 박스
Container(
margin: const EdgeInsets.all(4), // 외부 여백만 적용
decoration: BoxDecoration(
color: Colors.white, // 흰색 배경
borderRadius: BorderRadius.circular(24), // 라운드 처리
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
padding: const EdgeInsets.all(32), // 내부 여백 유지
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 통계 카드 그리드
Container(
margin: const EdgeInsets.only(bottom: 32),
child: StatsGrid(
totalCompanies: _controller.totalCompanies,
totalUsers: _controller.totalUsers,
totalLicenses: _controller.totalLicenses,
totalEquipmentIn: _controller.totalEquipmentIn,
totalEquipmentOut: _controller.totalEquipmentOut,
),
),
_buildActivitySection(),
const SizedBox(height: 32),
_buildRecentItemsSection(),
],
),
),
],
),
),
),
);
}
Widget _buildActivitySection() {
// MetronicCard로 감싸고, 섹션 헤더 스타일 통일
return MetronicCard(
title: '시스템 활동',
margin: const EdgeInsets.only(bottom: 32),
child: Column(
children: [
_buildActivityChart(),
const SizedBox(height: 20),
const Divider(color: Color(0xFFF3F6F9)),
const SizedBox(height: 20),
_buildActivityLegend(),
],
),
);
}
Widget _buildActivityChart() {
// Metronic 스타일: 카드 내부 차트 영역, 라운드, 밝은 배경, 컬러 강조
return Container(
height: 200,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppThemeTailwind.light,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.bar_chart,
size: 56,
color: AppThemeTailwind.primary,
),
const SizedBox(height: 18),
Text('월별 장비 입/출고 추이', style: AppThemeTailwind.subheadingStyle),
const SizedBox(height: 10),
Text(
'실제 구현 시 차트 라이브러리 (fl_chart 등) 사용',
style: AppThemeTailwind.smallText,
),
],
),
);
}
Widget _buildActivityLegend() {
// Metronic 스타일: 라운드, 컬러, 폰트 통일
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLegendItem('장비 입고', AppThemeTailwind.success),
const SizedBox(width: 32),
_buildLegendItem('장비 출고', AppThemeTailwind.warning),
const SizedBox(width: 32),
_buildLegendItem('라이센스 등록', AppThemeTailwind.info),
],
);
}
Widget _buildLegendItem(String text, Color color) {
// Metronic 스타일: 컬러 원, 텍스트, 여백
return Row(
children: [
Container(
width: 14,
height: 14,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 10),
Text(
text,
style: AppThemeTailwind.smallText.copyWith(
fontWeight: FontWeight.w600,
color: AppThemeTailwind.dark,
),
),
],
);
}
Widget _buildRecentItemsSection() {
// Metronic 스타일: 카드, 섹션 헤더, 리스트 여백/컬러 통일
return MetronicCard(
title: '최근 활동',
child: Column(
children: [
const Divider(indent: 0, endIndent: 0, color: Color(0xFFF3F6F9)),
const SizedBox(height: 16),
RecentActivitiesList(recentActivities: _controller.recentActivities),
const SizedBox(height: 8),
],
),
);
}
}

View File

@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
// 최근 활동 리스트 위젯 (SRP, 재사용성)
class RecentActivitiesList extends StatelessWidget {
final List<Map<String, dynamic>> recentActivities;
const RecentActivitiesList({super.key, required this.recentActivities});
@override
Widget build(BuildContext context) {
return Column(
children:
recentActivities.map((activity) {
return Column(
children: [
ListTile(
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: activity['color'] as Color,
shape: BoxShape.circle,
),
child: Icon(
activity['icon'] as IconData,
color: Colors.white,
size: 20,
),
),
title: Text(
activity['title'] as String,
style: AppThemeTailwind.subheadingStyle,
),
subtitle: Text(
'${activity['type']}${activity['user']}',
style: AppThemeTailwind.smallText,
),
trailing: Text(
activity['time'] as String,
style: AppThemeTailwind.smallText.copyWith(
color: AppThemeTailwind.muted,
),
),
),
if (activity != recentActivities.last)
const Divider(
height: 1,
indent: 68,
endIndent: 16,
color: (Color(0xFFEEEEF2)),
),
],
);
}).toList(),
);
}
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
import 'package:superport/screens/common/layout_components.dart';
// 대시보드 통계 카드 그리드 위젯 (SRP, 재사용성)
class StatsGrid extends StatelessWidget {
final int totalCompanies;
final int totalUsers;
final int totalLicenses;
final int totalEquipmentIn;
final int totalEquipmentOut;
const StatsGrid({
super.key,
required this.totalCompanies,
required this.totalUsers,
required this.totalLicenses,
required this.totalEquipmentIn,
required this.totalEquipmentOut,
});
@override
Widget build(BuildContext context) {
return GridView.count(
crossAxisCount: 3,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
shrinkWrap: true,
childAspectRatio: 2.5,
physics: const NeverScrollableScrollPhysics(),
children: [
MetronicStatsCard(
title: '등록된 회사',
value: '$totalCompanies',
icon: Icons.business,
iconBackgroundColor: AppThemeTailwind.info,
showTrend: true,
trendPercentage: 2.5,
isPositiveTrend: true,
),
MetronicStatsCard(
title: '등록된 사용자',
value: '$totalUsers',
icon: Icons.person,
iconBackgroundColor: AppThemeTailwind.primary,
showTrend: true,
trendPercentage: 3.7,
isPositiveTrend: true,
),
MetronicStatsCard(
title: '유효 라이센스',
value: '$totalLicenses',
icon: Icons.vpn_key,
iconBackgroundColor: AppThemeTailwind.secondary,
),
MetronicStatsCard(
title: '총 장비 입고',
value: '$totalEquipmentIn',
icon: Icons.input,
iconBackgroundColor: AppThemeTailwind.success,
showTrend: true,
trendPercentage: 1.8,
isPositiveTrend: true,
),
MetronicStatsCard(
title: '총 장비 출고',
value: '$totalEquipmentOut',
icon: Icons.output,
iconBackgroundColor: AppThemeTailwind.warning,
),
MetronicStatsCard(
title: '현재 재고',
value: '${totalEquipmentIn - totalEquipmentOut}',
icon: Icons.inventory_2,
iconBackgroundColor: AppThemeTailwind.danger,
showTrend: true,
trendPercentage: 0.7,
isPositiveTrend: false,
),
],
);
}
}

View File

@@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/screens/sidebar/widgets/sidebar_menu_header.dart';
import 'package:superport/screens/sidebar/widgets/sidebar_menu_footer.dart';
import 'package:superport/screens/sidebar/widgets/sidebar_menu_item.dart';
import 'package:superport/screens/sidebar/widgets/sidebar_menu_submenu.dart';
import 'package:superport/screens/sidebar/widgets/sidebar_menu_types.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
import 'package:superport/screens/login/widgets/login_view.dart'; // AnimatedBoatIcon import
import 'package:wave/wave.dart';
import 'package:wave/config.dart';
// 사이드바 메뉴 메인 위젯 (조립만 담당)
class SidebarMenu extends StatefulWidget {
final String currentRoute;
final Function(String) onRouteChanged;
const SidebarMenu({
super.key,
required this.currentRoute,
required this.onRouteChanged,
});
@override
State<SidebarMenu> createState() => _SidebarMenuState();
}
class _SidebarMenuState extends State<SidebarMenu> {
// 장비 관리 메뉴 확장 상태
bool _isEquipmentMenuExpanded = false;
// hover 상태 관리
String? _hoveredRoute;
@override
void initState() {
super.initState();
_updateExpandedState();
}
@override
void didUpdateWidget(SidebarMenu oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.currentRoute != widget.currentRoute) {
_updateExpandedState();
}
}
// 현재 경로에 따라 장비 관리 메뉴 확장 상태 업데이트
void _updateExpandedState() {
final bool isEquipmentRoute =
widget.currentRoute == Routes.equipment ||
widget.currentRoute == Routes.equipmentInList ||
widget.currentRoute == Routes.equipmentOutList ||
widget.currentRoute == Routes.equipmentRentList;
setState(() {
_isEquipmentMenuExpanded = isEquipmentRoute;
});
}
// 장비 관리 메뉴 확장/축소 토글
void _toggleEquipmentMenu() {
setState(() {
_isEquipmentMenuExpanded = !_isEquipmentMenuExpanded;
});
}
@override
Widget build(BuildContext context) {
// SRP 분할: 각 역할별 위젯 조립
return Container(
width: 260,
color: const Color(0xFFF4F6F8), // 연회색 배경
child: Column(
children: [
const SidebarMenuHeader(),
Expanded(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SidebarMenuItem(
icon: Icons.dashboard,
title: '대시보드',
route: Routes.home,
isActive: widget.currentRoute == Routes.home,
isHovered: _hoveredRoute == Routes.home,
onTap: () => widget.onRouteChanged(Routes.home),
),
const SizedBox(height: 4),
SidebarMenuWithSubmenu(
icon: Icons.inventory,
title: '장비 관리',
route: Routes.equipment,
subItems: const [
SidebarSubMenuItem(
title: '입고',
route: Routes.equipmentInList,
),
SidebarSubMenuItem(
title: '출고',
route: Routes.equipmentOutList,
),
SidebarSubMenuItem(
title: '대여',
route: Routes.equipmentRentList,
),
],
isExpanded: _isEquipmentMenuExpanded,
isMenuActive: widget.currentRoute == Routes.equipment,
isSubMenuActive: [
Routes.equipmentInList,
Routes.equipmentOutList,
Routes.equipmentRentList,
].contains(widget.currentRoute),
isHovered: _hoveredRoute == Routes.equipment,
onToggleExpanded: _toggleEquipmentMenu,
currentRoute: widget.currentRoute,
onRouteChanged: widget.onRouteChanged,
),
const SizedBox(height: 4),
SidebarMenuItem(
icon: Icons.location_on,
title: '입고지 관리',
route: Routes.warehouseLocation,
isActive: widget.currentRoute == Routes.warehouseLocation,
isHovered: _hoveredRoute == Routes.warehouseLocation,
onTap:
() => widget.onRouteChanged(Routes.warehouseLocation),
),
const SizedBox(height: 4),
SidebarMenuItem(
icon: Icons.business,
title: '회사 관리',
route: Routes.company,
isActive: widget.currentRoute == Routes.company,
isHovered: _hoveredRoute == Routes.company,
onTap: () => widget.onRouteChanged(Routes.company),
),
const SizedBox(height: 4),
SidebarMenuItem(
icon: Icons.vpn_key,
title: '유지보수 관리',
route: Routes.license,
isActive: widget.currentRoute == Routes.license,
isHovered: _hoveredRoute == Routes.license,
onTap: () => widget.onRouteChanged(Routes.license),
),
const SizedBox(height: 4),
SidebarMenuItem(
icon: Icons.category,
title: '물품 관리',
route: Routes.goods,
isActive: widget.currentRoute == Routes.goods,
isHovered: _hoveredRoute == Routes.goods,
onTap: () => widget.onRouteChanged(Routes.goods),
),
],
),
),
),
),
const SidebarMenuFooter(),
],
),
);
}
}

View File

@@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
// 사이드바 푸터 위젯
class SidebarMenuFooter extends StatelessWidget {
const SidebarMenuFooter({super.key});
@override
Widget build(BuildContext context) {
return Container(
height: 48,
alignment: Alignment.center,
child: const Text(
'© 2025 CClabs. All rights reserved.',
style: TextStyle(fontSize: 11, color: Colors.black), // 블랙으로 변경
),
);
}
}

View File

@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'package:wave/wave.dart';
import 'package:wave/config.dart';
import 'package:superport/screens/login/widgets/login_view.dart'; // AnimatedBoatIcon import
// 사이드바 헤더 위젯
class SidebarMenuHeader extends StatelessWidget {
const SidebarMenuHeader({super.key});
@override
Widget build(BuildContext context) {
return Container(
height: 88,
width: double.infinity,
padding: const EdgeInsets.only(left: 0, right: 0), // 아이콘을 더 좌측으로
child: Stack(
alignment: Alignment.centerLeft,
children: [
// Wave 배경
Positioned.fill(
child: Opacity(
opacity: 0.50, // subtle하게
child: WaveWidget(
config: CustomConfig(
gradients: [
[Color(0xFFB6E0FE), Color(0xFF3182CE)],
[
Color.fromARGB(255, 31, 83, 132),
Color.fromARGB(255, 9, 49, 92),
],
],
durations: [4800, 6000],
heightPercentages: [0.48, 0.38],
blur: const MaskFilter.blur(BlurStyle.solid, 6),
gradientBegin: Alignment.topLeft,
gradientEnd: Alignment.bottomRight,
),
waveAmplitude: 8,
size: Size.infinite,
),
),
),
// 아이콘+텍스트
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(width: 24), // 아이콘을 더 좌측으로
SizedBox(
width: 36,
height: 36,
child: AnimatedBoatIcon(color: Colors.white, size: 60),
),
const SizedBox(width: 24),
const Text(
'supERPort',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
letterSpacing: -2.5,
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
// 단일 메뉴 아이템 위젯
class SidebarMenuItem extends StatelessWidget {
final IconData icon;
final String title;
final String route;
final bool isActive;
final bool isHovered;
final bool isSubItem;
final VoidCallback onTap;
const SidebarMenuItem({
super.key,
required this.icon,
required this.title,
required this.route,
required this.isActive,
required this.isHovered,
this.isSubItem = false,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: onTap,
child: Container(
height: 44,
alignment: Alignment.centerLeft,
margin: const EdgeInsets.symmetric(
vertical: 2,
horizontal: 6,
), // 외부 여백
padding: EdgeInsets.only(left: isSubItem ? 48 : 24, right: 24),
decoration: BoxDecoration(
color:
isActive
? Colors.white
: (isHovered
? const Color(0xFFE9EDF2)
: Colors.transparent),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(
icon,
size: 18,
color:
isActive ? AppThemeTailwind.primary : AppThemeTailwind.dark,
),
const SizedBox(width: 10),
Text(
title,
style: TextStyle(
color:
isActive
? AppThemeTailwind.primary
: AppThemeTailwind.dark,
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
fontSize: 14,
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/sidebar/widgets/sidebar_menu_item.dart';
import 'package:superport/screens/sidebar/widgets/sidebar_menu_types.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
// 서브메뉴(확장/축소, 하위 아이템) 위젯
class SidebarMenuWithSubmenu extends StatelessWidget {
final IconData icon;
final String title;
final String route;
final List<SidebarSubMenuItem> subItems;
final bool isExpanded;
final bool isMenuActive;
final bool isSubMenuActive;
final bool isHovered;
final VoidCallback onToggleExpanded;
final String currentRoute;
final void Function(String) onRouteChanged;
const SidebarMenuWithSubmenu({
super.key,
required this.icon,
required this.title,
required this.route,
required this.subItems,
required this.isExpanded,
required this.isMenuActive,
required this.isSubMenuActive,
required this.isHovered,
required this.onToggleExpanded,
required this.currentRoute,
required this.onRouteChanged,
});
@override
Widget build(BuildContext context) {
final bool isHighlighted = isMenuActive || isSubMenuActive;
return Column(
children: [
MouseRegion(
cursor: SystemMouseCursors.click,
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () {
onToggleExpanded();
onRouteChanged(route);
},
child: Container(
height: 44,
margin: const EdgeInsets.symmetric(vertical: 2, horizontal: 6),
padding: const EdgeInsets.only(left: 24, right: 24),
decoration: BoxDecoration(
color:
isMenuActive
? Colors.white
: (isHovered
? const Color(0xFFE9EDF2)
: Colors.transparent),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(
icon,
size: 18,
color:
isHighlighted
? AppThemeTailwind.primary
: AppThemeTailwind.dark,
),
const SizedBox(width: 10),
Text(
title,
style: TextStyle(
color:
isHighlighted
? AppThemeTailwind.primary
: AppThemeTailwind.dark,
fontWeight:
isHighlighted ? FontWeight.bold : FontWeight.normal,
fontSize: 14,
),
),
const Spacer(),
Icon(
isExpanded
? Icons.keyboard_arrow_up
: Icons.keyboard_arrow_down,
size: 20,
color: AppThemeTailwind.muted,
),
],
),
),
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
child: ClipRect(
child: Align(
alignment: Alignment.topCenter,
heightFactor: isExpanded ? 1 : 0,
child: Column(
children:
subItems.map((item) {
return SidebarMenuItem(
icon: Icons.circle,
title: item.title,
route: item.route,
isActive: currentRoute == item.route,
isHovered: false, // hover는 상위에서 관리
isSubItem: true,
onTap: () => onRouteChanged(item.route),
);
}).toList(),
),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,11 @@
// 서브메뉴 아이템 타입 정의 파일
// 이 파일은 사이드바 메뉴에서 사용하는 서브메뉴 아이템 타입만 정의합니다.
class SidebarSubMenuItem {
// 서브메뉴의 제목
final String title;
// 서브메뉴의 라우트
final String route;
const SidebarSubMenuItem({required this.title, required this.route});
}

View File

@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/models/user_model.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/models/user_phone_field.dart';
// 사용자 폼의 상태 및 비즈니스 로직을 담당하는 컨트롤러
class UserFormController {
final MockDataService dataService;
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
bool isEditMode = false;
int? userId;
String name = '';
int? companyId;
int? branchId;
String role = UserRoles.member;
String position = '';
String email = '';
// 전화번호 관련 상태
final List<UserPhoneField> phoneFields = [];
final List<String> phoneTypes = ['휴대폰', '사무실', '팩스', '기타'];
List<Company> companies = [];
List<Branch> branches = [];
UserFormController({required this.dataService, this.userId});
// 회사 목록 로드
void loadCompanies() {
companies = dataService.getAllCompanies();
}
// 회사 ID에 따라 지점 목록 로드
void loadBranches(int companyId) {
final company = dataService.getCompanyById(companyId);
branches = company?.branches ?? [];
// 지점 변경 시 이전 선택 지점이 새 회사에 없으면 초기화
if (branchId != null && !branches.any((b) => b.id == branchId)) {
branchId = null;
}
}
// 사용자 정보 로드 (수정 모드)
void loadUser() {
if (userId == null) return;
final user = dataService.getUserById(userId!);
if (user != null) {
name = user.name;
companyId = user.companyId;
branchId = user.branchId;
role = user.role;
position = user.position ?? '';
email = user.email ?? '';
if (companyId != null) {
loadBranches(companyId!);
}
phoneFields.clear();
if (user.phoneNumbers.isNotEmpty) {
for (var phone in user.phoneNumbers) {
phoneFields.add(
UserPhoneField(
type: phone['type'] ?? '휴대폰',
initialValue: phone['number'] ?? '',
),
);
}
} else {
addPhoneField();
}
}
}
// 전화번호 필드 추가
void addPhoneField() {
phoneFields.add(UserPhoneField(type: '휴대폰'));
}
// 전화번호 필드 삭제
void removePhoneField(int index) {
if (phoneFields.length > 1) {
phoneFields[index].dispose();
phoneFields.removeAt(index);
}
}
// 사용자 저장 (UI에서 호출)
void saveUser(Function(String? error) onResult) {
if (formKey.currentState?.validate() != true) {
onResult('폼 유효성 검사 실패');
return;
}
formKey.currentState?.save();
if (companyId == null) {
onResult('소속 회사를 선택해주세요');
return;
}
// 전화번호 목록 준비 (UserPhoneField 기반)
List<Map<String, String>> phoneNumbersList = [];
for (var phoneField in phoneFields) {
if (phoneField.number.isNotEmpty) {
phoneNumbersList.add({
'type': phoneField.type,
'number': phoneField.number,
});
}
}
if (isEditMode && userId != null) {
final user = dataService.getUserById(userId!);
if (user != null) {
final updatedUser = User(
id: user.id,
companyId: companyId!,
branchId: branchId,
name: name,
role: role,
position: position.isNotEmpty ? position : null,
email: email.isNotEmpty ? email : null,
phoneNumbers: phoneNumbersList,
);
dataService.updateUser(updatedUser);
}
} else {
final newUser = User(
companyId: companyId!,
branchId: branchId,
name: name,
role: role,
position: position.isNotEmpty ? position : null,
email: email.isNotEmpty ? email : null,
phoneNumbers: phoneNumbersList,
);
dataService.addUser(newUser);
}
onResult(null);
}
// 컨트롤러 해제
void dispose() {
for (var phoneField in phoneFields) {
phoneField.dispose();
}
}
}

View File

@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:superport/models/user_model.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/utils/user_utils.dart';
/// 담당자 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러
class UserListController extends ChangeNotifier {
final MockDataService dataService;
List<User> users = [];
UserListController({required this.dataService});
/// 사용자 목록 데이터 로드
void loadUsers() {
users = dataService.getAllUsers();
notifyListeners();
}
/// 사용자 삭제
void deleteUser(int id, VoidCallback onDeleted) {
dataService.deleteUser(id);
loadUsers();
onDeleted();
}
/// 권한명 반환 함수는 user_utils.dart의 getRoleName을 사용
/// 회사 ID와 지점 ID로 지점명 조회
String getBranchName(int companyId, int? branchId) {
final company = dataService.getCompanyById(companyId);
if (company == null || company.branches == null || branchId == null) {
return '-';
}
final branch = company.branches!.firstWhere(
(b) => b.id == branchId,
orElse: () => Branch(companyId: companyId, name: '-'),
);
return branch.name;
}
}

View File

@@ -0,0 +1,293 @@
import 'package:flutter/material.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/models/user_model.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
import 'package:superport/screens/common/custom_widgets.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/utils/validators.dart';
import 'package:flutter/services.dart';
import 'package:superport/screens/user/controllers/user_form_controller.dart';
import 'package:superport/models/user_phone_field.dart';
import 'package:superport/screens/common/widgets/company_branch_dropdown.dart';
// 사용자 등록/수정 화면 (UI만 담당, 상태/로직 분리)
class UserFormScreen extends StatefulWidget {
final int? userId;
const UserFormScreen({super.key, this.userId});
@override
State<UserFormScreen> createState() => _UserFormScreenState();
}
class _UserFormScreenState extends State<UserFormScreen> {
late final UserFormController _controller;
@override
void initState() {
super.initState();
_controller = UserFormController(
dataService: MockDataService(),
userId: widget.userId,
);
_controller.isEditMode = widget.userId != null;
_controller.loadCompanies();
if (_controller.isEditMode) {
_controller.loadUser();
} else if (_controller.phoneFields.isEmpty) {
_controller.addPhoneField();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(_controller.isEditMode ? '사용자 수정' : '사용자 등록')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _controller.formKey,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 이름
_buildTextField(
label: '이름',
initialValue: _controller.name,
hintText: '사용자 이름을 입력하세요',
validator: (value) => validateRequired(value, '이름'),
onSaved: (value) => _controller.name = value!,
),
// 직급
_buildTextField(
label: '직급',
initialValue: _controller.position,
hintText: '직급을 입력하세요',
onSaved: (value) => _controller.position = value ?? '',
),
// 소속 회사/지점
CompanyBranchDropdown(
companies: _controller.companies,
selectedCompanyId: _controller.companyId,
selectedBranchId: _controller.branchId,
branches: _controller.branches,
onCompanyChanged: (value) {
setState(() {
_controller.companyId = value;
_controller.branchId = null;
if (value != null) {
_controller.loadBranches(value);
} else {
_controller.branches = [];
}
});
},
onBranchChanged: (value) {
setState(() {
_controller.branchId = value;
});
},
),
// 이메일
_buildTextField(
label: '이메일',
initialValue: _controller.email,
hintText: '이메일을 입력하세요',
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) return null;
return validateEmail(value);
},
onSaved: (value) => _controller.email = value ?? '',
),
// 전화번호
_buildPhoneFieldsSection(),
// 권한
_buildRoleRadio(),
const SizedBox(height: 24),
// 저장 버튼
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _onSaveUser,
style: AppThemeTailwind.primaryButtonStyle,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Text(
_controller.isEditMode ? '수정하기' : '등록하기',
style: const TextStyle(fontSize: 16),
),
),
),
),
],
),
),
),
),
);
}
// 이름/직급/이메일 등 공통 텍스트 필드 위젯
Widget _buildTextField({
required String label,
required String initialValue,
required String hintText,
TextInputType? keyboardType,
List<TextInputFormatter>? inputFormatters,
String? Function(String?)? validator,
void Function(String?)? onSaved,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
TextFormField(
initialValue: initialValue,
decoration: InputDecoration(hintText: hintText),
keyboardType: keyboardType,
inputFormatters: inputFormatters,
validator: validator,
onSaved: onSaved,
),
],
),
);
}
// 전화번호 입력 필드 섹션 위젯 (UserPhoneField 기반)
Widget _buildPhoneFieldsSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('전화번호', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
..._controller.phoneFields.asMap().entries.map((entry) {
final i = entry.key;
final phoneField = entry.value;
return Row(
children: [
// 종류 드롭다운
DropdownButton<String>(
value: phoneField.type,
items:
_controller.phoneTypes
.map(
(type) =>
DropdownMenuItem(value: type, child: Text(type)),
)
.toList(),
onChanged: (value) {
setState(() {
phoneField.type = value!;
});
},
),
const SizedBox(width: 8),
// 번호 입력
Expanded(
child: TextFormField(
controller: phoneField.controller,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(hintText: '전화번호'),
onSaved: (value) {}, // 값은 controller에서 직접 추출
),
),
IconButton(
icon: const Icon(Icons.remove_circle, color: Colors.red),
onPressed:
_controller.phoneFields.length > 1
? () {
setState(() {
_controller.removePhoneField(i);
});
}
: null,
),
],
);
}),
// 추가 버튼
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () {
setState(() {
_controller.addPhoneField();
});
},
icon: const Icon(Icons.add),
label: const Text('전화번호 추가'),
),
),
],
);
}
// 권한(관리등급) 라디오 위젯
Widget _buildRoleRadio() {
return Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('권한', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: RadioListTile<String>(
title: const Text('관리자'),
value: UserRoles.admin,
groupValue: _controller.role,
onChanged: (value) {
setState(() {
_controller.role = value!;
});
},
),
),
Expanded(
child: RadioListTile<String>(
title: const Text('일반 사용자'),
value: UserRoles.member,
groupValue: _controller.role,
onChanged: (value) {
setState(() {
_controller.role = value!;
});
},
),
),
],
),
],
),
);
}
// 저장 버튼 클릭 시 사용자 저장
void _onSaveUser() {
setState(() {
_controller.saveUser((error) {
if (error != null) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error)));
} else {
Navigator.pop(context, true);
}
});
});
}
}

View File

@@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/models/user_model.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
import 'package:superport/screens/common/main_layout.dart';
import 'package:superport/screens/common/custom_widgets.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/screens/user/controllers/user_list_controller.dart';
import 'package:superport/screens/user/widgets/user_table.dart';
import 'package:superport/utils/user_utils.dart';
import 'package:superport/screens/common/widgets/pagination.dart';
// 담당자 목록 화면 (UI만 담당)
class UserListScreen extends StatefulWidget {
const UserListScreen({super.key});
@override
State<UserListScreen> createState() => _UserListScreenState();
}
class _UserListScreenState extends State<UserListScreen> {
late final UserListController _controller;
final MockDataService _dataService = MockDataService();
// 페이지네이션 상태 추가
int _currentPage = 1;
final int _pageSize = 10;
@override
void initState() {
super.initState();
_controller = UserListController(dataService: _dataService);
_controller.loadUsers();
_controller.addListener(_refresh);
}
@override
void dispose() {
_controller.removeListener(_refresh);
super.dispose();
}
// 상태 갱신용 setState 래퍼
void _refresh() {
setState(() {});
}
// 사용자 추가 화면 이동
void _navigateToAddScreen() async {
final result = await Navigator.pushNamed(context, '/user/add');
if (result == true) {
_controller.loadUsers();
}
}
// 사용자 수정 화면 이동
void _navigateToEditScreen(int id) async {
final result = await Navigator.pushNamed(
context,
'/user/edit',
arguments: id,
);
if (result == true) {
_controller.loadUsers();
}
}
// 사용자 삭제 다이얼로그
void _showDeleteDialog(int id) {
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: const Text('삭제 확인'),
content: const Text('이 사용자 정보를 삭제하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소'),
),
TextButton(
onPressed: () {
_controller.deleteUser(id, () {
Navigator.pop(context);
});
},
child: const Text('삭제'),
),
],
),
);
}
// 회사명 반환 함수 (내부에서만 사용)
String _getCompanyName(int companyId) {
final company = _dataService.getCompanyById(companyId);
return company?.name ?? '-';
}
@override
Widget build(BuildContext context) {
// 대시보드 폭에 맞게 조정
final screenWidth = MediaQuery.of(context).size.width;
final maxContentWidth = screenWidth > 1200 ? 1200.0 : screenWidth - 32;
// 페이지네이션 데이터 슬라이싱
final int totalCount = _controller.users.length;
final int startIndex = (_currentPage - 1) * _pageSize;
final int endIndex =
(startIndex + _pageSize) > totalCount
? totalCount
: (startIndex + _pageSize);
final pagedUsers = _controller.users.sublist(startIndex, endIndex);
return MainLayout(
title: '담당자 관리',
currentRoute: Routes.user,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _controller.loadUsers,
color: Colors.grey,
),
],
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageTitle(
title: '담당자 목록',
width: maxContentWidth - 32,
rightWidget: ElevatedButton.icon(
onPressed: _navigateToAddScreen,
icon: const Icon(Icons.add),
label: const Text('추가'),
style: AppThemeTailwind.primaryButtonStyle,
),
),
Expanded(
child: DataTableCard(
width: maxContentWidth - 32,
child: UserTable(
users: pagedUsers,
width: maxContentWidth - 32,
getRoleName: getRoleName,
getBranchName: _controller.getBranchName,
getCompanyName: _getCompanyName,
onEdit: _navigateToEditScreen,
onDelete: _showDeleteDialog,
),
),
),
// 페이지네이션 위젯 추가
if (totalCount > _pageSize)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Pagination(
totalCount: totalCount,
currentPage: _currentPage,
pageSize: _pageSize,
onPageChanged: (page) {
setState(() {
_currentPage = page;
});
},
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:superport/models/user_model.dart';
/// 사용자 목록 테이블 위젯 (SRP, 재사용성 중심)
class UserTable extends StatelessWidget {
final List<User> users;
final double width;
final String Function(String role) getRoleName;
final String Function(int companyId, int? branchId) getBranchName;
final String Function(int companyId) getCompanyName;
final void Function(int userId) onEdit;
final void Function(int userId) onDelete;
const UserTable({
super.key,
required this.users,
required this.width,
required this.getRoleName,
required this.getBranchName,
required this.getCompanyName,
required this.onEdit,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
return users.isEmpty
? const Center(child: Text('등록된 사용자 정보가 없습니다.'))
: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Container(
constraints: BoxConstraints(minWidth: width - 32),
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: DataTable(
columns: const [
DataColumn(label: Text('번호')),
DataColumn(label: Text('이름')),
DataColumn(label: Text('직급')),
DataColumn(label: Text('소속 회사')),
DataColumn(label: Text('소속 지점')),
DataColumn(label: Text('이메일')),
DataColumn(label: Text('전화번호')),
DataColumn(label: Text('권한')),
DataColumn(label: Text('관리')),
],
rows:
users.map((user) {
return DataRow(
cells: [
DataCell(Text('${user.id}')),
DataCell(Text(user.name)),
DataCell(Text(user.position ?? '-')),
DataCell(Text(getCompanyName(user.companyId))),
DataCell(
Text(
user.branchId != null
? getBranchName(user.companyId, user.branchId)
: '-',
),
),
DataCell(Text(user.email ?? '-')),
DataCell(
user.phoneNumbers.isNotEmpty
? Text(user.phoneNumbers.first['number'] ?? '-')
: const Text('-'),
),
DataCell(Text(getRoleName(user.role))),
DataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(
Icons.edit,
color: Colors.blue,
),
onPressed: () => onEdit(user.id!),
),
IconButton(
icon: const Icon(
Icons.delete,
color: Colors.red,
),
onPressed: () => onDelete(user.id!),
),
],
),
),
],
);
}).toList(),
),
),
),
);
}
}

View File

@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:superport/models/warehouse_location_model.dart';
import 'package:superport/models/address_model.dart';
import 'package:superport/services/mock_data_service.dart';
/// 입고지 폼 상태 및 저장/수정 로직을 담당하는 컨트롤러
class WarehouseLocationFormController {
/// 폼 키
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
/// 입고지명 입력 컨트롤러
final TextEditingController nameController = TextEditingController();
/// 비고 입력 컨트롤러
final TextEditingController remarkController = TextEditingController();
/// 주소 정보
Address address = const Address();
/// 저장 중 여부
bool isSaving = false;
/// 수정 모드 여부
bool isEditMode = false;
/// 입고지 id (수정 모드)
int? id;
/// 기존 데이터 세팅 (수정 모드)
void initialize(int? locationId) {
id = locationId;
if (id != null) {
final location = MockDataService().getWarehouseLocationById(id!);
if (location != null) {
isEditMode = true;
nameController.text = location.name;
address = location.address;
remarkController.text = location.remark ?? '';
}
}
}
/// 주소 변경 처리
void updateAddress(Address newAddress) {
address = newAddress;
}
/// 저장 처리 (추가/수정)
Future<bool> save(BuildContext context) async {
if (!formKey.currentState!.validate()) return false;
isSaving = true;
if (isEditMode) {
// 수정
MockDataService().updateWarehouseLocation(
WarehouseLocation(
id: id!,
name: nameController.text.trim(),
address: address,
remark: remarkController.text.trim(),
),
);
} else {
// 추가
MockDataService().addWarehouseLocation(
WarehouseLocation(
id: 0,
name: nameController.text.trim(),
address: address,
remark: remarkController.text.trim(),
),
);
}
isSaving = false;
Navigator.pop(context, true);
return true;
}
/// 취소 처리
void cancel(BuildContext context) {
Navigator.pop(context, false);
}
/// 컨트롤러 해제
void dispose() {
nameController.dispose();
remarkController.dispose();
}
}

View File

@@ -0,0 +1,36 @@
import 'package:superport/models/warehouse_location_model.dart';
import 'package:superport/services/mock_data_service.dart';
/// 입고지 리스트 상태 및 CRUD만 담당하는 컨트롤러 클래스 (SRP 적용)
/// UI, 네비게이션, 다이얼로그 등은 포함하지 않음
/// 향후 서비스/리포지토리 DI 구조로 확장 가능
class WarehouseLocationListController {
/// 입고지 데이터 서비스 (mock)
final MockDataService _dataService = MockDataService();
/// 전체 입고지 목록
List<WarehouseLocation> warehouseLocations = [];
/// 데이터 로드
void loadWarehouseLocations() {
warehouseLocations = _dataService.getAllWarehouseLocations();
}
/// 입고지 추가
void addWarehouseLocation(WarehouseLocation location) {
_dataService.addWarehouseLocation(location);
loadWarehouseLocations();
}
/// 입고지 수정
void updateWarehouseLocation(WarehouseLocation location) {
_dataService.updateWarehouseLocation(location);
loadWarehouseLocations();
}
/// 입고지 삭제
void deleteWarehouseLocation(int id) {
_dataService.deleteWarehouseLocation(id);
loadWarehouseLocations();
}
}

View File

@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:superport/models/address_model.dart';
import 'package:superport/screens/common/widgets/address_input.dart';
import 'package:superport/screens/common/widgets/remark_input.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
import 'package:superport/utils/constants.dart';
import 'controllers/warehouse_location_form_controller.dart';
/// 입고지 추가/수정 폼 화면 (SRP 적용, 상태/로직 분리)
class WarehouseLocationFormScreen extends StatefulWidget {
final int? id; // 수정 모드 지원을 위한 id 파라미터
const WarehouseLocationFormScreen({Key? key, this.id}) : super(key: key);
@override
State<WarehouseLocationFormScreen> createState() =>
_WarehouseLocationFormScreenState();
}
class _WarehouseLocationFormScreenState
extends State<WarehouseLocationFormScreen> {
/// 폼 컨트롤러 (상태 및 저장/수정 로직 위임)
late final WarehouseLocationFormController _controller;
@override
void initState() {
super.initState();
// 컨트롤러 생성 및 초기화
_controller = WarehouseLocationFormController();
_controller.initialize(widget.id);
}
@override
void dispose() {
// 컨트롤러 해제
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_controller.isEditMode ? '입고지 수정' : '입고지 추가'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).maybePop(),
),
),
body: SafeArea(
child: Form(
key: _controller.formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 입고지명 입력
TextFormField(
controller: _controller.nameController,
decoration: const InputDecoration(
labelText: '입고지명',
hintText: '입고지명을 입력하세요',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '입고지명을 입력하세요';
}
return null;
},
),
const SizedBox(height: 24),
// 주소 입력 (공통 위젯)
AddressInput(
initialZipCode: _controller.address.zipCode,
initialRegion: _controller.address.region,
initialDetailAddress: _controller.address.detailAddress,
isRequired: true,
onAddressChanged: (zip, region, detail) {
setState(() {
_controller.updateAddress(
Address(
zipCode: zip,
region: region,
detailAddress: detail,
),
);
});
},
),
const SizedBox(height: 24),
// 비고 입력
RemarkInput(controller: _controller.remarkController),
const SizedBox(height: 80), // 하단 버튼 여백 확보
],
),
),
),
),
bottomNavigationBar: Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
child: SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed:
_controller.isSaving
? null
: () async {
setState(() {}); // 저장 중 상태 갱신
await _controller.save(context);
setState(() {}); // 저장 완료 후 상태 갱신
},
style: ElevatedButton.styleFrom(
backgroundColor: AppThemeTailwind.primary,
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child:
_controller.isSaving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text(
'저장',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:superport/models/warehouse_location_model.dart';
import 'package:superport/models/address_model.dart';
import 'package:superport/services/mock_data_service.dart';
/// 입고지 폼 상태 및 저장/수정 로직을 담당하는 컨트롤러
class WarehouseLocationFormController {
/// 폼 키
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
/// 입고지명 입력 컨트롤러
final TextEditingController nameController = TextEditingController();
/// 비고 입력 컨트롤러
final TextEditingController remarkController = TextEditingController();
/// 주소 정보
Address address = const Address();
/// 저장 중 여부
bool isSaving = false;
/// 수정 모드 여부
bool isEditMode = false;
/// 입고지 id (수정 모드)
int? id;
/// 기존 데이터 세팅 (수정 모드)
void initialize(int? locationId) {
id = locationId;
if (id != null) {
final location = MockDataService().getWarehouseLocationById(id!);
if (location != null) {
isEditMode = true;
nameController.text = location.name;
address = location.address;
remarkController.text = location.remark ?? '';
}
}
}
/// 주소 변경 처리
void updateAddress(Address newAddress) {
address = newAddress;
}
/// 저장 처리 (추가/수정)
Future<bool> save(BuildContext context) async {
if (!formKey.currentState!.validate()) return false;
isSaving = true;
if (isEditMode) {
// 수정
MockDataService().updateWarehouseLocation(
WarehouseLocation(
id: id!,
name: nameController.text.trim(),
address: address,
remark: remarkController.text.trim(),
),
);
} else {
// 추가
MockDataService().addWarehouseLocation(
WarehouseLocation(
id: 0,
name: nameController.text.trim(),
address: address,
remark: remarkController.text.trim(),
),
);
}
isSaving = false;
Navigator.pop(context, true);
return true;
}
/// 취소 처리
void cancel(BuildContext context) {
Navigator.pop(context, false);
}
/// 컨트롤러 해제
void dispose() {
nameController.dispose();
remarkController.dispose();
}
}

View File

@@ -0,0 +1,218 @@
import 'package:flutter/material.dart';
import 'package:superport/models/warehouse_location_model.dart';
import 'controllers/warehouse_location_list_controller.dart';
import 'package:superport/screens/common/widgets/address_input.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/screens/common/main_layout.dart';
import 'package:superport/screens/common/widgets/pagination.dart';
import 'package:superport/screens/common/custom_widgets.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
/// 입고지 관리 리스트 화면 (SRP 적용, UI만 담당)
class WarehouseLocationListScreen extends StatefulWidget {
const WarehouseLocationListScreen({Key? key}) : super(key: key);
@override
State<WarehouseLocationListScreen> createState() =>
_WarehouseLocationListScreenState();
}
class _WarehouseLocationListScreenState
extends State<WarehouseLocationListScreen> {
/// 리스트 컨트롤러 (상태 및 CRUD 위임)
final WarehouseLocationListController _controller =
WarehouseLocationListController();
int _currentPage = 1;
final int _pageSize = 10;
@override
void initState() {
super.initState();
_controller.loadWarehouseLocations();
}
/// 리스트 새로고침
void _reload() {
setState(() {
_controller.loadWarehouseLocations();
});
}
/// 입고지 추가 폼으로 이동
void _navigateToAdd() async {
final result = await Navigator.pushNamed(
context,
Routes.warehouseLocationAdd,
);
if (result == true) {
_reload();
}
}
/// 입고지 수정 폼으로 이동
void _navigateToEdit(WarehouseLocation location) async {
final result = await Navigator.pushNamed(
context,
Routes.warehouseLocationEdit,
arguments: location.id,
);
if (result == true) {
_reload();
}
}
/// 삭제 다이얼로그 (별도 위젯으로 분리 가능)
void _showDeleteDialog(int id) {
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: const Text('입고지 삭제'),
content: const Text('정말로 삭제하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('취소'),
),
TextButton(
onPressed: () {
setState(() {
_controller.deleteWarehouseLocation(id);
});
Navigator.of(context).pop();
},
child: const Text('삭제'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
// 대시보드 폭에 맞게 조정
final screenWidth = MediaQuery.of(context).size.width;
final maxContentWidth = screenWidth > 1200 ? 1200.0 : screenWidth - 32;
final int totalCount = _controller.warehouseLocations.length;
final int startIndex = (_currentPage - 1) * _pageSize;
final int endIndex =
(startIndex + _pageSize) > totalCount
? totalCount
: (startIndex + _pageSize);
final List<WarehouseLocation> pagedLocations = _controller
.warehouseLocations
.sublist(startIndex, endIndex);
return MainLayout(
title: '입고지 관리',
currentRoute: Routes.warehouseLocation,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _reload,
color: Colors.grey,
),
],
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageTitle(
title: '입고지 목록',
width: maxContentWidth - 32,
rightWidget: ElevatedButton.icon(
onPressed: _navigateToAdd,
icon: const Icon(Icons.add),
label: const Text('입고지 추가'),
style: AppThemeTailwind.primaryButtonStyle,
),
),
Expanded(
child: DataTableCard(
width: maxContentWidth - 32,
child:
pagedLocations.isEmpty
? const Center(child: Text('등록된 입고지가 없습니다.'))
: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Container(
width: maxContentWidth - 32,
constraints: BoxConstraints(
minWidth: maxContentWidth - 64,
),
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: DataTable(
columns: const [
DataColumn(label: Text('번호')),
DataColumn(label: Text('입고지명')),
DataColumn(label: Text('주소')),
DataColumn(label: Text('관리')),
],
rows: List.generate(pagedLocations.length, (i) {
final location = pagedLocations[i];
return DataRow(
cells: [
DataCell(Text('${startIndex + i + 1}')),
DataCell(Text(location.name)),
DataCell(
AddressInput.readonly(
address: location.address,
),
),
DataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(
Icons.edit,
color: AppThemeTailwind.primary,
),
tooltip: '수정',
onPressed:
() =>
_navigateToEdit(location),
),
IconButton(
icon: const Icon(
Icons.delete,
color: AppThemeTailwind.danger,
),
tooltip: '삭제',
onPressed:
() => _showDeleteDialog(
location.id,
),
),
],
),
),
],
);
}),
),
),
),
),
),
),
const SizedBox(height: 16),
Pagination(
currentPage: _currentPage,
totalCount: totalCount,
pageSize: _pageSize,
onPageChanged: (page) {
setState(() {
_currentPage = page;
});
},
),
],
),
),
);
}
}