프로젝트 최초 커밋
This commit is contained in:
BIN
lib/assets/fonts/NotoSansKR-VariableFont_wght.ttf
Normal file
BIN
lib/assets/fonts/NotoSansKR-VariableFont_wght.ttf
Normal file
Binary file not shown.
178
lib/main.dart
Normal file
178
lib/main.dart
Normal file
@@ -0,0 +1,178 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:superport/models/equipment_unified_model.dart';
|
||||
import 'package:superport/screens/common/app_layout.dart';
|
||||
import 'package:superport/screens/common/theme_tailwind.dart';
|
||||
import 'package:superport/screens/company/company_form.dart';
|
||||
import 'package:superport/screens/equipment/equipment_in_form.dart';
|
||||
import 'package:superport/screens/equipment/equipment_out_form.dart';
|
||||
import 'package:superport/screens/license/license_form.dart'; // MaintenanceFormScreen으로 사용
|
||||
import 'package:superport/screens/user/user_form.dart';
|
||||
import 'package:superport/screens/warehouse_location/warehouse_location_form.dart';
|
||||
import 'package:superport/utils/constants.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:superport/screens/login/login_screen.dart';
|
||||
|
||||
void main() {
|
||||
// MockDataService는 싱글톤으로 자동 초기화됨
|
||||
runApp(const SuperportApp());
|
||||
}
|
||||
|
||||
class SuperportApp extends StatelessWidget {
|
||||
const SuperportApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'supERPort',
|
||||
theme: AppThemeTailwind.lightTheme,
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const [Locale('ko', 'KR'), Locale('en', 'US')],
|
||||
locale: const Locale('ko', 'KR'),
|
||||
initialRoute: '/login',
|
||||
onGenerateRoute: (settings) {
|
||||
// 로그인 라우트 처리
|
||||
if (settings.name == '/login') {
|
||||
return MaterialPageRoute(builder: (context) => const LoginScreen());
|
||||
}
|
||||
// 기본 AppLayout으로 라우팅할 경로 (홈, 목록 화면들)
|
||||
if (settings.name == Routes.home ||
|
||||
settings.name == Routes.equipment ||
|
||||
settings.name == Routes.equipmentInList ||
|
||||
settings.name == Routes.equipmentOutList ||
|
||||
settings.name == Routes.equipmentRentList ||
|
||||
settings.name == Routes.company ||
|
||||
settings.name == Routes.user ||
|
||||
settings.name == Routes.license) {
|
||||
return MaterialPageRoute(
|
||||
builder: (context) => AppLayout(initialRoute: settings.name!),
|
||||
);
|
||||
}
|
||||
|
||||
// 기존 라우팅 처리 (폼 화면들)
|
||||
switch (settings.name) {
|
||||
// 장비 입고 관련 라우트
|
||||
case Routes.equipmentInAdd:
|
||||
return MaterialPageRoute(
|
||||
builder: (context) => const EquipmentInFormScreen(),
|
||||
);
|
||||
case Routes.equipmentInEdit:
|
||||
final id = settings.arguments as int;
|
||||
return MaterialPageRoute(
|
||||
builder: (context) => EquipmentInFormScreen(equipmentInId: id),
|
||||
);
|
||||
|
||||
// 장비 출고 관련 라우트
|
||||
case Routes.equipmentOutAdd:
|
||||
// 선택된 장비 정보와 입고 ID가 전달되었는지 확인
|
||||
final args = settings.arguments;
|
||||
Equipment? equipment;
|
||||
int? equipmentInId;
|
||||
List<Map<String, dynamic>>? selectedEquipments;
|
||||
|
||||
// 인자 처리
|
||||
if (args is Map<String, dynamic>) {
|
||||
// 다중 선택 장비 처리
|
||||
if (args.containsKey('selectedEquipments')) {
|
||||
selectedEquipments =
|
||||
args['selectedEquipments'] as List<Map<String, dynamic>>;
|
||||
debugPrint('선택된 장비 목록: ${selectedEquipments.length}개');
|
||||
} else {
|
||||
// 단일 장비 선택 (기존 방식)
|
||||
equipment = args['equipment'] as Equipment?;
|
||||
equipmentInId = args['equipmentInId'] as int?;
|
||||
debugPrint('단일 장비 선택');
|
||||
}
|
||||
} else if (args is List<Map<String, dynamic>>) {
|
||||
// 직접 리스트가 전달된 경우
|
||||
selectedEquipments = args;
|
||||
debugPrint('직접 리스트로 전달된 장비 목록: ${selectedEquipments.length}개');
|
||||
} else if (args is Equipment) {
|
||||
equipment = args; // 기존 방식 대응 (하위 호환)
|
||||
debugPrint('단일 Equipment 객체 전달');
|
||||
} else {
|
||||
debugPrint('알 수 없는 인자 타입: ${args.runtimeType}');
|
||||
}
|
||||
|
||||
return MaterialPageRoute(
|
||||
builder:
|
||||
(context) => EquipmentOutFormScreen(
|
||||
selectedEquipment: equipment,
|
||||
selectedEquipmentInId: equipmentInId,
|
||||
selectedEquipments: selectedEquipments,
|
||||
),
|
||||
);
|
||||
case Routes.equipmentOutEdit:
|
||||
final id = settings.arguments as int;
|
||||
return MaterialPageRoute(
|
||||
builder: (context) => EquipmentOutFormScreen(equipmentOutId: id),
|
||||
);
|
||||
|
||||
// 회사 관련 라우트
|
||||
case Routes.companyAdd:
|
||||
return MaterialPageRoute(
|
||||
builder: (context) => const CompanyFormScreen(),
|
||||
);
|
||||
case Routes.companyEdit:
|
||||
final args = settings.arguments;
|
||||
if (args is Map) {
|
||||
return MaterialPageRoute(
|
||||
builder: (context) => CompanyFormScreen(args: args),
|
||||
);
|
||||
} else if (args is int) {
|
||||
// 하위 호환: int만 넘어오는 경우
|
||||
return MaterialPageRoute(
|
||||
builder:
|
||||
(context) => CompanyFormScreen(args: {'companyId': args}),
|
||||
);
|
||||
} else {
|
||||
return MaterialPageRoute(
|
||||
builder: (context) => CompanyFormScreen(),
|
||||
);
|
||||
}
|
||||
|
||||
// 사용자 관련 라우트
|
||||
case Routes.userAdd:
|
||||
return MaterialPageRoute(
|
||||
builder: (context) => const UserFormScreen(),
|
||||
);
|
||||
case Routes.userEdit:
|
||||
final id = settings.arguments as int;
|
||||
return MaterialPageRoute(
|
||||
builder: (context) => UserFormScreen(userId: id),
|
||||
);
|
||||
|
||||
// 라이센스 관련 라우트
|
||||
case Routes.licenseAdd:
|
||||
return MaterialPageRoute(
|
||||
builder: (context) => const MaintenanceFormScreen(),
|
||||
);
|
||||
case Routes.licenseEdit:
|
||||
final id = settings.arguments as int;
|
||||
return MaterialPageRoute(
|
||||
builder: (context) => MaintenanceFormScreen(maintenanceId: id),
|
||||
);
|
||||
|
||||
// 입고지 관련 라우트
|
||||
case Routes.warehouseLocationAdd:
|
||||
return MaterialPageRoute(
|
||||
builder: (context) => const WarehouseLocationFormScreen(),
|
||||
);
|
||||
case Routes.warehouseLocationEdit:
|
||||
final id = settings.arguments as int;
|
||||
return MaterialPageRoute(
|
||||
builder: (context) => WarehouseLocationFormScreen(id: id),
|
||||
);
|
||||
|
||||
default:
|
||||
return MaterialPageRoute(
|
||||
builder: (context) => AppLayout(initialRoute: Routes.home),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
81
lib/models/address_model.dart
Normal file
81
lib/models/address_model.dart
Normal file
@@ -0,0 +1,81 @@
|
||||
/// 주소 모델
|
||||
///
|
||||
/// 우편번호, 시/도, 상세주소로 구성된 주소 정보를 관리합니다.
|
||||
/// 회사 및 지점의 주소 정보를 일관되게 처리하기 위한 모델입니다.
|
||||
class Address {
|
||||
/// 우편번호
|
||||
final String zipCode;
|
||||
|
||||
/// 시/도 (서울특별시, 경기도 등)
|
||||
final String region;
|
||||
|
||||
/// 상세 주소
|
||||
final String detailAddress;
|
||||
|
||||
/// 생성자
|
||||
const Address({this.zipCode = '', this.region = '', this.detailAddress = ''});
|
||||
|
||||
/// 주소를 문자열로 반환합니다. (전체 주소)
|
||||
///
|
||||
/// 예시: "12345 서울특별시 강남구 테헤란로 123"
|
||||
@override
|
||||
String toString() {
|
||||
final List<String> parts = [];
|
||||
|
||||
if (zipCode.isNotEmpty) {
|
||||
parts.add(zipCode);
|
||||
}
|
||||
|
||||
if (region.isNotEmpty) {
|
||||
parts.add(region);
|
||||
}
|
||||
|
||||
if (detailAddress.isNotEmpty) {
|
||||
parts.add(detailAddress);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/// 전체 주소에서 Address 객체를 생성합니다.
|
||||
///
|
||||
/// 현재는 우편번호, 시/도, 상세주소를 분리하지 않고 전체를 detailAddress로 저장합니다.
|
||||
/// 기존 데이터 마이그레이션을 위한 메서드입니다.
|
||||
factory Address.fromFullAddress(String fullAddress) {
|
||||
return Address(detailAddress: fullAddress);
|
||||
}
|
||||
|
||||
/// JSON에서 Address 객체를 생성합니다.
|
||||
factory Address.fromJson(Map<String, dynamic> json) {
|
||||
return Address(
|
||||
zipCode: json['zipCode'] ?? '',
|
||||
region: json['region'] ?? '',
|
||||
detailAddress: json['detailAddress'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// Address 객체를 JSON으로 변환합니다.
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'zipCode': zipCode,
|
||||
'region': region,
|
||||
'detailAddress': detailAddress,
|
||||
};
|
||||
}
|
||||
|
||||
/// 주소가 비어 있는지 확인합니다.
|
||||
bool get isEmpty =>
|
||||
zipCode.isEmpty && region.isEmpty && detailAddress.isEmpty;
|
||||
|
||||
/// 주소가 비어 있지 않은지 확인합니다.
|
||||
bool get isNotEmpty => !isEmpty;
|
||||
|
||||
/// 복사본을 생성하고 일부 필드를 업데이트합니다.
|
||||
Address copyWith({String? zipCode, String? region, String? detailAddress}) {
|
||||
return Address(
|
||||
zipCode: zipCode ?? this.zipCode,
|
||||
region: region ?? this.region,
|
||||
detailAddress: detailAddress ?? this.detailAddress,
|
||||
);
|
||||
}
|
||||
}
|
||||
259
lib/models/company_model.dart
Normal file
259
lib/models/company_model.dart
Normal file
@@ -0,0 +1,259 @@
|
||||
import 'package:superport/models/address_model.dart';
|
||||
|
||||
/// 회사 유형 열거형
|
||||
/// - 고객사: 서비스를 이용하는 회사
|
||||
/// - 파트너사: 서비스를 제공하는 회사
|
||||
enum CompanyType {
|
||||
customer, // 고객사
|
||||
partner, // 파트너사
|
||||
}
|
||||
|
||||
/// 회사 유형을 문자열로 변환 (복수 지원)
|
||||
String companyTypeToString(CompanyType type) {
|
||||
switch (type) {
|
||||
case CompanyType.customer:
|
||||
return '고객사';
|
||||
case CompanyType.partner:
|
||||
return '파트너사';
|
||||
}
|
||||
}
|
||||
|
||||
/// 문자열에서 회사 유형으로 변환 (단일)
|
||||
CompanyType stringToCompanyType(String type) {
|
||||
switch (type) {
|
||||
case '고객사':
|
||||
return CompanyType.customer;
|
||||
case '파트너사':
|
||||
return CompanyType.partner;
|
||||
default:
|
||||
return CompanyType.customer; // 기본값은 고객사
|
||||
}
|
||||
}
|
||||
|
||||
/// 문자열 리스트에서 회사 유형 리스트로 변환
|
||||
List<CompanyType> stringListToCompanyTypeList(List<dynamic> types) {
|
||||
// 문자열 또는 enum 문자열이 섞여 있을 수 있음
|
||||
return types.map((e) {
|
||||
if (e is CompanyType) return e;
|
||||
if (e is String) {
|
||||
if (e.contains('partner')) return CompanyType.partner;
|
||||
return CompanyType.customer;
|
||||
}
|
||||
return CompanyType.customer;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// 회사 유형 리스트를 문자열 리스트로 변환
|
||||
List<String> companyTypeListToStringList(List<CompanyType> types) {
|
||||
return types.map((e) => companyTypeToString(e)).toList();
|
||||
}
|
||||
|
||||
class Branch {
|
||||
final int? id;
|
||||
final int companyId;
|
||||
final String name;
|
||||
final Address address; // 주소 모델 사용
|
||||
final String? contactName; // 담당자 이름
|
||||
final String? contactPosition; // 담당자 직책
|
||||
final String? contactPhone; // 담당자 전화번호
|
||||
final String? contactEmail; // 담당자 이메일
|
||||
final String? remark; // 비고
|
||||
|
||||
Branch({
|
||||
this.id,
|
||||
required this.companyId,
|
||||
required this.name,
|
||||
Address? address, // 옵셔널 파라미터로 변경
|
||||
this.contactName,
|
||||
this.contactPosition,
|
||||
this.contactPhone,
|
||||
this.contactEmail,
|
||||
this.remark,
|
||||
}) : address = address ?? const Address(); // 기본값 제공
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'companyId': companyId,
|
||||
'name': name,
|
||||
'address': address.toString(), // 하위 호환성을 위해 문자열로 변환
|
||||
'addressData': address.toJson(), // 새로운 형식으로 저장
|
||||
'contactName': contactName,
|
||||
'contactPosition': contactPosition,
|
||||
'contactPhone': contactPhone,
|
||||
'contactEmail': contactEmail,
|
||||
'remark': remark,
|
||||
};
|
||||
}
|
||||
|
||||
factory Branch.fromJson(Map<String, dynamic> json) {
|
||||
// 주소 데이터가 새 형식으로 저장되어 있는지 확인
|
||||
Address addressData;
|
||||
if (json.containsKey('addressData')) {
|
||||
addressData = Address.fromJson(json['addressData']);
|
||||
} else if (json.containsKey('address') && json['address'] != null) {
|
||||
// 이전 버전 호환성 - 문자열 주소를 Address 객체로 변환
|
||||
addressData = Address.fromFullAddress(json['address']);
|
||||
} else {
|
||||
addressData = const Address();
|
||||
}
|
||||
|
||||
return Branch(
|
||||
id: json['id'],
|
||||
companyId: json['companyId'],
|
||||
name: json['name'],
|
||||
address: addressData,
|
||||
contactName: json['contactName'],
|
||||
contactPosition: json['contactPosition'],
|
||||
contactPhone: json['contactPhone'],
|
||||
contactEmail: json['contactEmail'],
|
||||
remark: json['remark'],
|
||||
);
|
||||
}
|
||||
|
||||
/// 복사본을 생성하고 일부 필드를 업데이트합니다.
|
||||
Branch copyWith({
|
||||
int? id,
|
||||
int? companyId,
|
||||
String? name,
|
||||
Address? address,
|
||||
String? contactName,
|
||||
String? contactPosition,
|
||||
String? contactPhone,
|
||||
String? contactEmail,
|
||||
String? remark,
|
||||
}) {
|
||||
return Branch(
|
||||
id: id ?? this.id,
|
||||
companyId: companyId ?? this.companyId,
|
||||
name: name ?? this.name,
|
||||
address: address ?? this.address,
|
||||
contactName: contactName ?? this.contactName,
|
||||
contactPosition: contactPosition ?? this.contactPosition,
|
||||
contactPhone: contactPhone ?? this.contactPhone,
|
||||
contactEmail: contactEmail ?? this.contactEmail,
|
||||
remark: remark ?? this.remark,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Company {
|
||||
final int? id;
|
||||
final String name;
|
||||
final Address address; // 주소 모델 사용
|
||||
final String? contactName; // 담당자 이름
|
||||
final String? contactPosition; // 담당자 직책
|
||||
final String? contactPhone; // 담당자 전화번호
|
||||
final String? contactEmail; // 담당자 이메일
|
||||
final List<Branch>? branches;
|
||||
final List<CompanyType> companyTypes; // 회사 유형 (복수 가능)
|
||||
final String? remark; // 비고
|
||||
|
||||
Company({
|
||||
this.id,
|
||||
required this.name,
|
||||
Address? address, // 옵셔널 파라미터로 변경
|
||||
this.contactName,
|
||||
this.contactPosition,
|
||||
this.contactPhone,
|
||||
this.contactEmail,
|
||||
this.branches,
|
||||
this.companyTypes = const [CompanyType.customer], // 기본값은 고객사
|
||||
this.remark,
|
||||
}) : address = address ?? const Address(); // 기본값 제공
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'address': address.toString(), // 하위 호환성을 위해 문자열로 변환
|
||||
'addressData': address.toJson(), // 새로운 형식으로 저장
|
||||
'contactName': contactName,
|
||||
'contactPosition': contactPosition,
|
||||
'contactPhone': contactPhone,
|
||||
'contactEmail': contactEmail,
|
||||
'branches': branches?.map((branch) => branch.toJson()).toList(),
|
||||
// 회사 유형을 문자열 리스트로 저장
|
||||
'companyTypes': companyTypes.map((e) => e.toString()).toList(),
|
||||
'remark': remark,
|
||||
};
|
||||
}
|
||||
|
||||
factory Company.fromJson(Map<String, dynamic> json) {
|
||||
List<Branch>? branchList;
|
||||
if (json['branches'] != null) {
|
||||
branchList =
|
||||
(json['branches'] as List)
|
||||
.map((branchJson) => Branch.fromJson(branchJson))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// 주소 데이터가 새 형식으로 저장되어 있는지 확인
|
||||
Address addressData;
|
||||
if (json.containsKey('addressData')) {
|
||||
addressData = Address.fromJson(json['addressData']);
|
||||
} else if (json.containsKey('address') && json['address'] != null) {
|
||||
// 이전 버전 호환성 - 문자열 주소를 Address 객체로 변환
|
||||
addressData = Address.fromFullAddress(json['address']);
|
||||
} else {
|
||||
addressData = const Address();
|
||||
}
|
||||
|
||||
// 회사 유형 파싱 (복수)
|
||||
List<CompanyType> types = [CompanyType.customer]; // 기본값
|
||||
if (json.containsKey('companyTypes')) {
|
||||
final raw = json['companyTypes'];
|
||||
if (raw is List) {
|
||||
types = stringListToCompanyTypeList(raw);
|
||||
}
|
||||
} else if (json.containsKey('companyType')) {
|
||||
// 이전 버전 호환성: 단일 값
|
||||
final raw = json['companyType'];
|
||||
if (raw is String) {
|
||||
types = [stringToCompanyType(raw)];
|
||||
} else if (raw is int) {
|
||||
types = [CompanyType.values[raw]];
|
||||
}
|
||||
}
|
||||
|
||||
return Company(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
address: addressData,
|
||||
contactName: json['contactName'],
|
||||
contactPosition: json['contactPosition'],
|
||||
contactPhone: json['contactPhone'],
|
||||
contactEmail: json['contactEmail'],
|
||||
branches: branchList,
|
||||
companyTypes: types,
|
||||
remark: json['remark'],
|
||||
);
|
||||
}
|
||||
|
||||
/// 복사본을 생성하고 일부 필드를 업데이트합니다.
|
||||
Company copyWith({
|
||||
int? id,
|
||||
String? name,
|
||||
Address? address,
|
||||
String? contactName,
|
||||
String? contactPosition,
|
||||
String? contactPhone,
|
||||
String? contactEmail,
|
||||
List<Branch>? branches,
|
||||
List<CompanyType>? companyTypes,
|
||||
String? remark,
|
||||
}) {
|
||||
return Company(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
address: address ?? this.address,
|
||||
contactName: contactName ?? this.contactName,
|
||||
contactPosition: contactPosition ?? this.contactPosition,
|
||||
contactPhone: contactPhone ?? this.contactPhone,
|
||||
contactEmail: contactEmail ?? this.contactEmail,
|
||||
branches: branches ?? this.branches,
|
||||
companyTypes: companyTypes ?? this.companyTypes,
|
||||
remark: remark ?? this.remark,
|
||||
);
|
||||
}
|
||||
}
|
||||
278
lib/models/equipment_unified_model.dart
Normal file
278
lib/models/equipment_unified_model.dart
Normal file
@@ -0,0 +1,278 @@
|
||||
import 'package:superport/utils/constants.dart';
|
||||
|
||||
// 장비 정보 모델
|
||||
class Equipment {
|
||||
final int? id;
|
||||
final String manufacturer;
|
||||
final String name;
|
||||
final String category;
|
||||
final String subCategory;
|
||||
final String subSubCategory;
|
||||
final String? serialNumber;
|
||||
final String? barcode;
|
||||
final int quantity;
|
||||
final DateTime? inDate;
|
||||
final String? remark; // 비고
|
||||
final String? warrantyLicense; // 워런티 라이센스 명
|
||||
DateTime? warrantyStartDate; // 워런티 시작일(수정 가능)
|
||||
DateTime? warrantyEndDate; // 워런티 종료일(수정 가능)
|
||||
|
||||
Equipment({
|
||||
this.id,
|
||||
required this.manufacturer,
|
||||
required this.name,
|
||||
required this.category,
|
||||
required this.subCategory,
|
||||
required this.subSubCategory,
|
||||
this.serialNumber,
|
||||
this.barcode,
|
||||
required this.quantity,
|
||||
this.inDate,
|
||||
this.remark,
|
||||
this.warrantyLicense,
|
||||
this.warrantyStartDate,
|
||||
this.warrantyEndDate,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'manufacturer': manufacturer,
|
||||
'name': name,
|
||||
'category': category,
|
||||
'subCategory': subCategory,
|
||||
'subSubCategory': subSubCategory,
|
||||
'serialNumber': serialNumber,
|
||||
'barcode': barcode,
|
||||
'quantity': quantity,
|
||||
'inDate': inDate?.toIso8601String(),
|
||||
'remark': remark,
|
||||
'warrantyLicense': warrantyLicense,
|
||||
'warrantyStartDate': warrantyStartDate?.toIso8601String(),
|
||||
'warrantyEndDate': warrantyEndDate?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
factory Equipment.fromJson(Map<String, dynamic> json) {
|
||||
return Equipment(
|
||||
id: json['id'],
|
||||
manufacturer: json['manufacturer'],
|
||||
name: json['name'],
|
||||
category: json['category'],
|
||||
subCategory: json['subCategory'],
|
||||
subSubCategory: json['subSubCategory'],
|
||||
serialNumber: json['serialNumber'],
|
||||
barcode: json['barcode'],
|
||||
quantity: json['quantity'],
|
||||
inDate: json['inDate'] != null ? DateTime.parse(json['inDate']) : null,
|
||||
remark: json['remark'],
|
||||
warrantyLicense: json['warrantyLicense'],
|
||||
warrantyStartDate:
|
||||
json['warrantyStartDate'] != null
|
||||
? DateTime.parse(json['warrantyStartDate'])
|
||||
: null,
|
||||
warrantyEndDate:
|
||||
json['warrantyEndDate'] != null
|
||||
? DateTime.parse(json['warrantyEndDate'])
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EquipmentIn {
|
||||
final int? id;
|
||||
final Equipment equipment;
|
||||
final DateTime inDate;
|
||||
final String status; // I (입고)
|
||||
final String type; // 장비 유형: '신제품', '중고', '계약'
|
||||
final String? warehouseLocation; // 입고지
|
||||
final String? partnerCompany; // 파트너사
|
||||
final String? remark; // 비고
|
||||
|
||||
EquipmentIn({
|
||||
this.id,
|
||||
required this.equipment,
|
||||
required this.inDate,
|
||||
this.status = 'I',
|
||||
this.type = EquipmentType.new_, // 기본값은 '신제품'으로 설정
|
||||
this.warehouseLocation,
|
||||
this.partnerCompany,
|
||||
this.remark,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'equipment': equipment.toJson(),
|
||||
'inDate': inDate.toIso8601String(),
|
||||
'status': status,
|
||||
'type': type,
|
||||
'warehouseLocation': warehouseLocation,
|
||||
'partnerCompany': partnerCompany,
|
||||
'remark': remark,
|
||||
};
|
||||
}
|
||||
|
||||
factory EquipmentIn.fromJson(Map<String, dynamic> json) {
|
||||
return EquipmentIn(
|
||||
id: json['id'],
|
||||
equipment: Equipment.fromJson(json['equipment']),
|
||||
inDate: DateTime.parse(json['inDate']),
|
||||
status: json['status'],
|
||||
type: json['type'] ?? EquipmentType.new_,
|
||||
warehouseLocation: json['warehouseLocation'],
|
||||
partnerCompany: json['partnerCompany'],
|
||||
remark: json['remark'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EquipmentOut {
|
||||
final int? id;
|
||||
final Equipment equipment;
|
||||
final DateTime outDate;
|
||||
final String status; // O (출고), I (재입고), R (수리)
|
||||
final String? company; // 출고 회사
|
||||
final String? manager; // 담당자
|
||||
final String? license; // 라이센스
|
||||
final DateTime? returnDate; // 재입고/수리 날짜
|
||||
final String? returnType; // 재입고/수리 유형
|
||||
final String? remark; // 비고
|
||||
|
||||
EquipmentOut({
|
||||
this.id,
|
||||
required this.equipment,
|
||||
required this.outDate,
|
||||
this.status = 'O',
|
||||
this.company,
|
||||
this.manager,
|
||||
this.license,
|
||||
this.returnDate,
|
||||
this.returnType,
|
||||
this.remark,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'equipment': equipment.toJson(),
|
||||
'outDate': outDate.toIso8601String(),
|
||||
'status': status,
|
||||
'company': company,
|
||||
'manager': manager,
|
||||
'license': license,
|
||||
'returnDate': returnDate?.toIso8601String(),
|
||||
'returnType': returnType,
|
||||
'remark': remark,
|
||||
};
|
||||
}
|
||||
|
||||
factory EquipmentOut.fromJson(Map<String, dynamic> json) {
|
||||
return EquipmentOut(
|
||||
id: json['id'],
|
||||
equipment: Equipment.fromJson(json['equipment']),
|
||||
outDate: DateTime.parse(json['outDate']),
|
||||
status: json['status'],
|
||||
company: json['company'],
|
||||
manager: json['manager'],
|
||||
license: json['license'],
|
||||
returnDate:
|
||||
json['returnDate'] != null
|
||||
? DateTime.parse(json['returnDate'])
|
||||
: null,
|
||||
returnType: json['returnType'],
|
||||
remark: json['remark'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UnifiedEquipment {
|
||||
final int? id;
|
||||
final Equipment equipment;
|
||||
final DateTime date; // 입고일 또는 출고일
|
||||
final String
|
||||
status; // 상태 코드: 'I'(입고), 'O'(출고), 'R'(수리중), 'D'(손상), 'L'(분실), 'E'(기타)
|
||||
final String? notes; // 추가 비고
|
||||
final String? _type; // 내부용: 입고 장비 유형
|
||||
|
||||
UnifiedEquipment({
|
||||
this.id,
|
||||
required this.equipment,
|
||||
required this.date,
|
||||
required this.status,
|
||||
this.notes,
|
||||
String? type,
|
||||
}) : _type = type;
|
||||
|
||||
// 장비 유형 반환 (입고 장비만)
|
||||
String? get type => status == 'I' ? _type : null;
|
||||
|
||||
// 장비 상태 텍스트 변환
|
||||
String get statusText {
|
||||
switch (status) {
|
||||
case EquipmentStatus.in_:
|
||||
return '입고';
|
||||
case EquipmentStatus.out:
|
||||
return '출고';
|
||||
case EquipmentStatus.rent:
|
||||
return '대여';
|
||||
case EquipmentStatus.repair:
|
||||
return '수리중';
|
||||
case EquipmentStatus.damaged:
|
||||
return '손상';
|
||||
case EquipmentStatus.lost:
|
||||
return '분실';
|
||||
case EquipmentStatus.etc:
|
||||
return '기타';
|
||||
default:
|
||||
return '알 수 없음';
|
||||
}
|
||||
}
|
||||
|
||||
// EquipmentIn 모델에서 변환
|
||||
factory UnifiedEquipment.fromEquipmentIn(
|
||||
id,
|
||||
equipment,
|
||||
inDate,
|
||||
status, {
|
||||
String? type,
|
||||
}) {
|
||||
return UnifiedEquipment(
|
||||
id: id,
|
||||
equipment: equipment,
|
||||
date: inDate,
|
||||
status: status,
|
||||
type: type,
|
||||
);
|
||||
}
|
||||
|
||||
// EquipmentOut 모델에서 변환
|
||||
factory UnifiedEquipment.fromEquipmentOut(id, equipment, outDate, status) {
|
||||
return UnifiedEquipment(
|
||||
id: id,
|
||||
equipment: equipment,
|
||||
date: outDate,
|
||||
status: status,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'equipment': equipment.toJson(),
|
||||
'date': date.toIso8601String(),
|
||||
'status': status,
|
||||
'notes': notes,
|
||||
};
|
||||
}
|
||||
|
||||
factory UnifiedEquipment.fromJson(Map<String, dynamic> json) {
|
||||
return UnifiedEquipment(
|
||||
id: json['id'],
|
||||
equipment: Equipment.fromJson(json['equipment']),
|
||||
date: DateTime.parse(json['date']),
|
||||
status: json['status'],
|
||||
notes: json['notes'],
|
||||
);
|
||||
}
|
||||
}
|
||||
35
lib/models/license_model.dart
Normal file
35
lib/models/license_model.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
class License {
|
||||
final int? id;
|
||||
final int companyId;
|
||||
final String name;
|
||||
final int durationMonths;
|
||||
final String visitCycle; // 방문주기(월, 격월, 분기 등)
|
||||
|
||||
License({
|
||||
this.id,
|
||||
required this.companyId,
|
||||
required this.name,
|
||||
required this.durationMonths,
|
||||
required this.visitCycle,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'companyId': companyId,
|
||||
'name': name,
|
||||
'durationMonths': durationMonths,
|
||||
'visitCycle': visitCycle,
|
||||
};
|
||||
}
|
||||
|
||||
factory License.fromJson(Map<String, dynamic> json) {
|
||||
return License(
|
||||
id: json['id'],
|
||||
companyId: json['companyId'],
|
||||
name: json['name'],
|
||||
durationMonths: json['durationMonths'],
|
||||
visitCycle: json['visitCycle'] as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
50
lib/models/user_model.dart
Normal file
50
lib/models/user_model.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
class User {
|
||||
final int? id;
|
||||
final int companyId;
|
||||
final int? branchId; // 지점 ID
|
||||
final String name;
|
||||
final String role; // 관리등급: S(관리자), M(멤버)
|
||||
final String? position; // 직급
|
||||
final String? email; // 이메일
|
||||
final List<Map<String, String>> phoneNumbers; // 전화번호 목록 (유형과 번호)
|
||||
|
||||
User({
|
||||
this.id,
|
||||
required this.companyId,
|
||||
this.branchId,
|
||||
required this.name,
|
||||
required this.role,
|
||||
this.position,
|
||||
this.email,
|
||||
this.phoneNumbers = const [],
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'companyId': companyId,
|
||||
'branchId': branchId,
|
||||
'name': name,
|
||||
'role': role,
|
||||
'position': position,
|
||||
'email': email,
|
||||
'phoneNumbers': phoneNumbers,
|
||||
};
|
||||
}
|
||||
|
||||
factory User.fromJson(Map<String, dynamic> json) {
|
||||
return User(
|
||||
id: json['id'],
|
||||
companyId: json['companyId'],
|
||||
branchId: json['branchId'],
|
||||
name: json['name'],
|
||||
role: json['role'],
|
||||
position: json['position'],
|
||||
email: json['email'],
|
||||
phoneNumbers:
|
||||
json['phoneNumbers'] != null
|
||||
? List<Map<String, String>>.from(json['phoneNumbers'])
|
||||
: [],
|
||||
);
|
||||
}
|
||||
}
|
||||
19
lib/models/user_phone_field.dart
Normal file
19
lib/models/user_phone_field.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
// 전화번호 입력 필드 관리를 위한 클래스
|
||||
// 타입 안정성 및 코드 간결성을 위해 사용
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class UserPhoneField {
|
||||
// 전화번호 종류(휴대폰, 사무실 등)
|
||||
String type;
|
||||
// 전화번호 입력 컨트롤러
|
||||
final TextEditingController controller;
|
||||
|
||||
UserPhoneField({required this.type, String? initialValue})
|
||||
: controller = TextEditingController(text: initialValue);
|
||||
|
||||
// 현재 입력된 전화번호 반환
|
||||
String get number => controller.text;
|
||||
|
||||
// 컨트롤러 해제
|
||||
void dispose() => controller.dispose();
|
||||
}
|
||||
38
lib/models/warehouse_location_model.dart
Normal file
38
lib/models/warehouse_location_model.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'address_model.dart';
|
||||
|
||||
/// 입고지 정보를 나타내는 모델 클래스
|
||||
class WarehouseLocation {
|
||||
/// 입고지 고유 번호
|
||||
final int id;
|
||||
|
||||
/// 입고지명
|
||||
final String name;
|
||||
|
||||
/// 입고지 주소
|
||||
final Address address;
|
||||
|
||||
/// 비고
|
||||
final String? remark;
|
||||
|
||||
WarehouseLocation({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.address,
|
||||
this.remark,
|
||||
});
|
||||
|
||||
/// 복사본 생성 (불변성 유지)
|
||||
WarehouseLocation copyWith({
|
||||
int? id,
|
||||
String? name,
|
||||
Address? address,
|
||||
String? remark,
|
||||
}) {
|
||||
return WarehouseLocation(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
address: address ?? this.address,
|
||||
remark: remark ?? this.remark,
|
||||
);
|
||||
}
|
||||
}
|
||||
83
lib/screens/common/app_layout.dart
Normal file
83
lib/screens/common/app_layout.dart
Normal 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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
8
lib/screens/common/custom_widgets.dart
Normal file
8
lib/screens/common/custom_widgets.dart
Normal 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';
|
||||
88
lib/screens/common/custom_widgets/autocomplete_dropdown.dart
Normal file
88
lib/screens/common/custom_widgets/autocomplete_dropdown.dart
Normal 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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
18
lib/screens/common/custom_widgets/category_data.dart
Normal file
18
lib/screens/common/custom_widgets/category_data.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
// 카테고리 데이터 (예시)
|
||||
final Map<String, Map<String, List<String>>> categoryData = {
|
||||
'컴퓨터': {
|
||||
'데스크탑': ['사무용', '게이밍', '워크스테이션'],
|
||||
'노트북': ['사무용', '게이밍', '울트라북'],
|
||||
'태블릿': ['안드로이드', 'iOS', '윈도우'],
|
||||
},
|
||||
'네트워크': {
|
||||
'라우터': ['가정용', '기업용', '산업용'],
|
||||
'스위치': ['관리형', '비관리형'],
|
||||
'액세스 포인트': ['실내용', '실외용'],
|
||||
},
|
||||
'주변기기': {
|
||||
'모니터': ['LCD', 'LED', 'OLED'],
|
||||
'키보드': ['유선', '무선', '기계식'],
|
||||
'마우스': ['유선', '무선', '트랙볼'],
|
||||
},
|
||||
};
|
||||
562
lib/screens/common/custom_widgets/category_selection_field.dart
Normal file
562
lib/screens/common/custom_widgets/category_selection_field.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
32
lib/screens/common/custom_widgets/data_table_card.dart
Normal file
32
lib/screens/common/custom_widgets/data_table_card.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
57
lib/screens/common/custom_widgets/date_picker_field.dart
Normal file
57
lib/screens/common/custom_widgets/date_picker_field.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
48
lib/screens/common/custom_widgets/form_field_wrapper.dart
Normal file
48
lib/screens/common/custom_widgets/form_field_wrapper.dart
Normal 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,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
53
lib/screens/common/custom_widgets/highlight_text.dart
Normal file
53
lib/screens/common/custom_widgets/highlight_text.dart
Normal 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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
27
lib/screens/common/custom_widgets/page_title.dart
Normal file
27
lib/screens/common/custom_widgets/page_title.dart
Normal 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!,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
9
lib/screens/common/layout_components.dart
Normal file
9
lib/screens/common/layout_components.dart
Normal 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';
|
||||
126
lib/screens/common/main_layout.dart
Normal file
126
lib/screens/common/main_layout.dart
Normal 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 '홈';
|
||||
}
|
||||
}
|
||||
}
|
||||
46
lib/screens/common/metronic_card.dart
Normal file
46
lib/screens/common/metronic_card.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
57
lib/screens/common/metronic_data_table.dart
Normal file
57
lib/screens/common/metronic_data_table.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
57
lib/screens/common/metronic_form_field.dart
Normal file
57
lib/screens/common/metronic_form_field.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
35
lib/screens/common/metronic_page_container.dart
Normal file
35
lib/screens/common/metronic_page_container.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
36
lib/screens/common/metronic_page_title.dart
Normal file
36
lib/screens/common/metronic_page_title.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
105
lib/screens/common/metronic_stats_card.dart
Normal file
105
lib/screens/common/metronic_stats_card.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
48
lib/screens/common/metronic_tab_container.dart
Normal file
48
lib/screens/common/metronic_tab_container.dart
Normal 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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
189
lib/screens/common/theme_tailwind.dart
Normal file
189
lib/screens/common/theme_tailwind.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
278
lib/screens/common/widgets/address_input.dart
Normal file
278
lib/screens/common/widgets/address_input.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
255
lib/screens/common/widgets/autocomplete_dropdown_field.dart
Normal file
255
lib/screens/common/widgets/autocomplete_dropdown_field.dart
Normal 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=[33m${widget.label}[0m, 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]),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
186
lib/screens/common/widgets/category_autocomplete_field.dart
Normal file
186
lib/screens/common/widgets/category_autocomplete_field.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
76
lib/screens/common/widgets/company_branch_dropdown.dart
Normal file
76
lib/screens/common/widgets/company_branch_dropdown.dart
Normal 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;
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
88
lib/screens/common/widgets/pagination.dart
Normal file
88
lib/screens/common/widgets/pagination.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
40
lib/screens/common/widgets/remark_input.dart
Normal file
40
lib/screens/common/widgets/remark_input.dart
Normal 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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
398
lib/screens/company/company_form.dart
Normal file
398
lib/screens/company/company_form.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
501
lib/screens/company/company_list.dart
Normal file
501
lib/screens/company/company_list.dart
Normal 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;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
121
lib/screens/company/controllers/branch_form_controller.dart
Normal file
121
lib/screens/company/controllers/branch_form_controller.dart
Normal 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는 위젯에서 자동 관리
|
||||
}
|
||||
}
|
||||
BIN
lib/screens/company/controllers/company_form_controller.dart
Normal file
BIN
lib/screens/company/controllers/company_form_controller.dart
Normal file
Binary file not shown.
141
lib/screens/company/widgets/branch_card.dart
Normal file
141
lib/screens/company/widgets/branch_card.dart
Normal 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로 변경하여 한 줄로 표시
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
112
lib/screens/company/widgets/branch_form_widget.dart
Normal file
112
lib/screens/company/widgets/branch_form_widget.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
374
lib/screens/company/widgets/company_branch_dialog.dart
Normal file
374
lib/screens/company/widgets/company_branch_dialog.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
85
lib/screens/company/widgets/company_form_header.dart
Normal file
85
lib/screens/company/widgets/company_form_header.dart
Normal 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
93
lib/screens/company/widgets/company_info_card.dart
Normal file
93
lib/screens/company/widgets/company_info_card.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
185
lib/screens/company/widgets/company_name_autocomplete.dart
Normal file
185
lib/screens/company/widgets/company_name_autocomplete.dart
Normal 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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
58
lib/screens/company/widgets/contact_info_form.dart
Normal file
58
lib/screens/company/widgets/contact_info_form.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
702
lib/screens/company/widgets/contact_info_widget.dart
Normal file
702
lib/screens/company/widgets/contact_info_widget.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
67
lib/screens/company/widgets/duplicate_company_dialog.dart
Normal file
67
lib/screens/company/widgets/duplicate_company_dialog.dart
Normal 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('확인'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
99
lib/screens/company/widgets/map_dialog.dart
Normal file
99
lib/screens/company/widgets/map_dialog.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
170
lib/screens/equipment/controllers/equipment_list_controller.dart
Normal file
170
lib/screens/equipment/controllers/equipment_list_controller.dart
Normal 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 '-';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
2267
lib/screens/equipment/equipment_in_form.dart
Normal file
2267
lib/screens/equipment/equipment_in_form.dart
Normal file
File diff suppressed because it is too large
Load Diff
696
lib/screens/equipment/equipment_list.dart
Normal file
696
lib/screens/equipment/equipment_list.dart
Normal 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;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
805
lib/screens/equipment/equipment_out_form.dart
Normal file
805
lib/screens/equipment/equipment_out_form.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
172
lib/screens/equipment/widgets/autocomplete_text_field.dart
Normal file
172
lib/screens/equipment/widgets/autocomplete_text_field.dart
Normal 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]),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
48
lib/screens/equipment/widgets/equipment_out_info.dart
Normal file
48
lib/screens/equipment/widgets/equipment_out_info.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
61
lib/screens/equipment/widgets/equipment_status_chip.dart
Normal file
61
lib/screens/equipment/widgets/equipment_status_chip.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
155
lib/screens/equipment/widgets/equipment_summary_card.dart
Normal file
155
lib/screens/equipment/widgets/equipment_summary_card.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
41
lib/screens/equipment/widgets/equipment_summary_row.dart
Normal file
41
lib/screens/equipment/widgets/equipment_summary_row.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
236
lib/screens/equipment/widgets/equipment_table.dart
Normal file
236
lib/screens/equipment/widgets/equipment_table.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
405
lib/screens/goods/goods_list.dart
Normal file
405
lib/screens/goods/goods_list.dart
Normal 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 ? '등록' : '수정'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
57
lib/screens/license/controllers/license_form_controller.dart
Normal file
57
lib/screens/license/controllers/license_form_controller.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
21
lib/screens/license/controllers/license_list_controller.dart
Normal file
21
lib/screens/license/controllers/license_list_controller.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
262
lib/screens/license/license_form.dart
Normal file
262
lib/screens/license/license_form.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
174
lib/screens/license/license_list.dart
Normal file
174
lib/screens/license/license_list.dart
Normal 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;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
71
lib/screens/license/widgets/license_table.dart
Normal file
71
lib/screens/license/widgets/license_table.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
41
lib/screens/login/controllers/login_controller.dart
Normal file
41
lib/screens/login/controllers/login_controller.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
32
lib/screens/login/login_screen.dart
Normal file
32
lib/screens/login/login_screen.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
301
lib/screens/login/widgets/login_view.dart
Normal file
301
lib/screens/login/widgets/login_view.dart
Normal 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('로그인'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
60
lib/screens/overview/controllers/overview_controller.dart
Normal file
60
lib/screens/overview/controllers/overview_controller.dart
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
206
lib/screens/overview/overview_screen.dart
Normal file
206
lib/screens/overview/overview_screen.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
56
lib/screens/overview/widgets/recent_activities_list.dart
Normal file
56
lib/screens/overview/widgets/recent_activities_list.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
83
lib/screens/overview/widgets/stats_grid.dart
Normal file
83
lib/screens/overview/widgets/stats_grid.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
169
lib/screens/sidebar/sidebar_screen.dart
Normal file
169
lib/screens/sidebar/sidebar_screen.dart
Normal 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(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
18
lib/screens/sidebar/widgets/sidebar_menu_footer.dart
Normal file
18
lib/screens/sidebar/widgets/sidebar_menu_footer.dart
Normal 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), // 블랙으로 변경
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
69
lib/screens/sidebar/widgets/sidebar_menu_header.dart
Normal file
69
lib/screens/sidebar/widgets/sidebar_menu_header.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
75
lib/screens/sidebar/widgets/sidebar_menu_item.dart
Normal file
75
lib/screens/sidebar/widgets/sidebar_menu_item.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
124
lib/screens/sidebar/widgets/sidebar_menu_submenu.dart
Normal file
124
lib/screens/sidebar/widgets/sidebar_menu_submenu.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
11
lib/screens/sidebar/widgets/sidebar_menu_types.dart
Normal file
11
lib/screens/sidebar/widgets/sidebar_menu_types.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
// 서브메뉴 아이템 타입 정의 파일
|
||||
// 이 파일은 사이드바 메뉴에서 사용하는 서브메뉴 아이템 타입만 정의합니다.
|
||||
|
||||
class SidebarSubMenuItem {
|
||||
// 서브메뉴의 제목
|
||||
final String title;
|
||||
// 서브메뉴의 라우트
|
||||
final String route;
|
||||
|
||||
const SidebarSubMenuItem({required this.title, required this.route});
|
||||
}
|
||||
146
lib/screens/user/controllers/user_form_controller.dart
Normal file
146
lib/screens/user/controllers/user_form_controller.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
42
lib/screens/user/controllers/user_list_controller.dart
Normal file
42
lib/screens/user/controllers/user_list_controller.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
293
lib/screens/user/user_form.dart
Normal file
293
lib/screens/user/user_form.dart
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
174
lib/screens/user/user_list.dart
Normal file
174
lib/screens/user/user_list.dart
Normal 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;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
98
lib/screens/user/widgets/user_table.dart
Normal file
98
lib/screens/user/widgets/user_table.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
139
lib/screens/warehouse_location/warehouse_location_form.dart
Normal file
139
lib/screens/warehouse_location/warehouse_location_form.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
218
lib/screens/warehouse_location/warehouse_location_list.dart
Normal file
218
lib/screens/warehouse_location/warehouse_location_list.dart
Normal 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;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1133
lib/services/mock_data_service.dart
Normal file
1133
lib/services/mock_data_service.dart
Normal file
File diff suppressed because it is too large
Load Diff
37
lib/utils/address_constants.dart
Normal file
37
lib/utils/address_constants.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
/// 주소 관련 상수 및 레이블 정의 파일
|
||||
///
|
||||
/// 한국의 시/도(광역시/도) 및 주소 입력 UI 레이블을 구분하여 관리합니다.
|
||||
|
||||
/// 한국의 시/도(광역시/도) 상수 클래스 (불변성 보장)
|
||||
class KoreanRegions {
|
||||
/// 최상위 행정구역(시/도)
|
||||
static const List<String> topLevel = [
|
||||
'서울특별시',
|
||||
'부산광역시',
|
||||
'대구광역시',
|
||||
'인천광역시',
|
||||
'광주광역시',
|
||||
'대전광역시',
|
||||
'울산광역시',
|
||||
'세종특별자치시',
|
||||
'경기도',
|
||||
'강원특별자치도',
|
||||
'충청북도',
|
||||
'충청남도',
|
||||
'전라북도',
|
||||
'전라남도',
|
||||
'경상북도',
|
||||
'경상남도',
|
||||
'제주특별자치도',
|
||||
];
|
||||
}
|
||||
|
||||
/// 주소 입력 관련 UI 레이블 상수 클래스
|
||||
class AddressLabels {
|
||||
static const String zipCode = '우편번호';
|
||||
static const String region = '시/도';
|
||||
static const String detail = '상세주소';
|
||||
static const String zipCodeHint = '우편번호를 입력하세요';
|
||||
static const String regionHint = '시/도를 선택하세요';
|
||||
static const String detailHint = '나머지 주소를 입력하세요';
|
||||
}
|
||||
59
lib/utils/constants.dart
Normal file
59
lib/utils/constants.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
/// 앱 전역에서 사용하는 상수 정의 파일
|
||||
///
|
||||
/// 라우트, 장비 상태, 장비 유형, 사용자 권한 등 도메인별로 구분하여 관리합니다.
|
||||
|
||||
/// 라우트 이름 상수 클래스
|
||||
class Routes {
|
||||
static const String home = '/';
|
||||
static const String equipment = '/equipment'; // 통합 장비 관리
|
||||
static const String equipmentIn = '/equipment-in'; // 입고 목록(미사용)
|
||||
static const String equipmentInAdd = '/equipment-in/add'; // 장비 입고 폼
|
||||
static const String equipmentInEdit = '/equipment-in/edit'; // 장비 입고 편집
|
||||
static const String equipmentOut = '/equipment-out'; // 출고 목록(미사용)
|
||||
static const String equipmentOutAdd = '/equipment-out/add'; // 장비 출고 폼
|
||||
static const String equipmentOutEdit = '/equipment-out/edit'; // 장비 출고 편집
|
||||
static const String equipmentInList = '/equipment/in'; // 입고 장비 목록
|
||||
static const String equipmentOutList = '/equipment/out'; // 출고 장비 목록
|
||||
static const String equipmentRentList = '/equipment/rent'; // 대여 장비 목록
|
||||
static const String company = '/company';
|
||||
static const String companyAdd = '/company/add';
|
||||
static const String companyEdit = '/company/edit';
|
||||
static const String user = '/user';
|
||||
static const String userAdd = '/user/add';
|
||||
static const String userEdit = '/user/edit';
|
||||
static const String license = '/license';
|
||||
static const String licenseAdd = '/license/add';
|
||||
static const String licenseEdit = '/license/edit';
|
||||
static const String warehouseLocation = '/warehouse-location'; // 입고지 관리 목록
|
||||
static const String warehouseLocationAdd =
|
||||
'/warehouse-location/add'; // 입고지 추가
|
||||
static const String warehouseLocationEdit =
|
||||
'/warehouse-location/edit'; // 입고지 수정
|
||||
static const String goods = '/goods'; // 물품 관리(등록)
|
||||
static const String goodsAdd = '/goods/add'; // 물품 등록 폼
|
||||
static const String goodsEdit = '/goods/edit'; // 물품 수정 폼
|
||||
}
|
||||
|
||||
/// 장비 상태 코드 상수 클래스
|
||||
class EquipmentStatus {
|
||||
static const String in_ = 'I'; // 입고
|
||||
static const String out = 'O'; // 출고
|
||||
static const String rent = 'T'; // 대여
|
||||
static const String repair = 'R'; // 수리
|
||||
static const String damaged = 'D'; // 손상
|
||||
static const String lost = 'L'; // 분실
|
||||
static const String etc = 'E'; // 기타
|
||||
}
|
||||
|
||||
/// 장비 유형 상수 클래스
|
||||
class EquipmentType {
|
||||
static const String new_ = '신제품'; // 신제품
|
||||
static const String used = '중고'; // 중고
|
||||
static const String contract = '계약'; // 계약(입고후 즉각 출고)
|
||||
}
|
||||
|
||||
/// 사용자 권한 상수 클래스
|
||||
class UserRoles {
|
||||
static const String admin = 'S'; // 관리자
|
||||
static const String member = 'M'; // 멤버
|
||||
}
|
||||
40
lib/utils/equipment_display_helper.dart
Normal file
40
lib/utils/equipment_display_helper.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
/// 장비 정보 표시를 위한 헬퍼 클래스 (SRP, 재사용성, 테스트 용이성 중심)
|
||||
class EquipmentDisplayHelper {
|
||||
/// 제조사명 포맷팅 (빈 값은 대시로 표시)
|
||||
static String formatManufacturer(String? manufacturer) {
|
||||
if (manufacturer == null || manufacturer.isEmpty) return '-';
|
||||
return manufacturer;
|
||||
}
|
||||
|
||||
/// 장비명 포맷팅 (빈 값은 대시로 표시)
|
||||
static String formatEquipmentName(String? name) {
|
||||
if (name == null || name.isEmpty) return '-';
|
||||
return name;
|
||||
}
|
||||
|
||||
/// 카테고리 포맷팅 (비어있지 않은 카테고리만 합침)
|
||||
static String formatCategory(
|
||||
String? category,
|
||||
String? subCategory,
|
||||
String? subSubCategory,
|
||||
) {
|
||||
final parts = [
|
||||
if (category != null && category.isNotEmpty) category,
|
||||
if (subCategory != null && subCategory.isNotEmpty) subCategory,
|
||||
if (subSubCategory != null && subSubCategory.isNotEmpty) subSubCategory,
|
||||
];
|
||||
if (parts.isEmpty) return '-';
|
||||
return parts.join(' > ');
|
||||
}
|
||||
|
||||
/// 시리얼 번호 포맷팅 (없으면 대시)
|
||||
static String formatSerialNumber(String? serialNumber) {
|
||||
return serialNumber?.isNotEmpty == true ? serialNumber! : '-';
|
||||
}
|
||||
|
||||
/// 날짜 포맷팅 (YYYY-MM-DD, null이면 대시)
|
||||
static String formatDate(DateTime? date) {
|
||||
if (date == null) return '-';
|
||||
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
127
lib/utils/phone_utils.dart
Normal file
127
lib/utils/phone_utils.dart
Normal file
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// 전화번호 관련 유틸리티 클래스 (SRP, 재사용성, 테스트 용이성 중심)
|
||||
class PhoneUtils {
|
||||
/// 전화번호 입력 형식 지정용 InputFormatter
|
||||
static final TextInputFormatter phoneInputFormatter =
|
||||
_PhoneTextInputFormatter();
|
||||
|
||||
/// 전화번호 포맷팅 (뒤 4자리 하이픈)
|
||||
static String formatPhoneNumber(String phoneNumber) {
|
||||
final digitsOnly = phoneNumber.replaceAll(RegExp(r'[^\d]'), '');
|
||||
if (digitsOnly.isEmpty) return '';
|
||||
if (digitsOnly.length > 8) {
|
||||
return formatPhoneNumber(digitsOnly.substring(0, 8));
|
||||
}
|
||||
if (digitsOnly.length > 4) {
|
||||
final frontPart = digitsOnly.substring(0, digitsOnly.length - 4);
|
||||
final backPart = digitsOnly.substring(digitsOnly.length - 4);
|
||||
return '$frontPart-$backPart';
|
||||
}
|
||||
return digitsOnly;
|
||||
}
|
||||
|
||||
/// 포맷된 전화번호에서 숫자만 추출
|
||||
static String extractDigitsOnly(String formattedPhoneNumber) {
|
||||
return formattedPhoneNumber.replaceAll(RegExp(r'[^\d]'), '');
|
||||
}
|
||||
|
||||
/// 전체 전화번호에서 접두사 추출 (없으면 기본값)
|
||||
static String extractPhonePrefix(
|
||||
String fullNumber,
|
||||
List<String> phonePrefixes,
|
||||
) {
|
||||
if (fullNumber.isEmpty) return '010';
|
||||
String digitsOnly = fullNumber.replaceAll(RegExp(r'[^\d]'), '');
|
||||
for (String prefix in phonePrefixes) {
|
||||
if (digitsOnly.startsWith(prefix)) {
|
||||
return prefix;
|
||||
}
|
||||
}
|
||||
return '010';
|
||||
}
|
||||
|
||||
/// 접두사 제외한 번호 추출
|
||||
static String extractPhoneNumberWithoutPrefix(
|
||||
String fullNumber,
|
||||
List<String> phonePrefixes,
|
||||
) {
|
||||
if (fullNumber.isEmpty) return '';
|
||||
String digitsOnly = fullNumber.replaceAll(RegExp(r'[^\d]'), '');
|
||||
for (String prefix in phonePrefixes) {
|
||||
if (digitsOnly.startsWith(prefix)) {
|
||||
return digitsOnly.substring(prefix.length);
|
||||
}
|
||||
}
|
||||
return digitsOnly;
|
||||
}
|
||||
|
||||
/// 접두사와 번호를 합쳐 전체 전화번호 생성
|
||||
static String getFullPhoneNumber(String prefix, String number) {
|
||||
final remainingNumber = number.replaceAll(RegExp(r'[^\d]'), '');
|
||||
if (remainingNumber.isEmpty) return '';
|
||||
return '$prefix-$remainingNumber';
|
||||
}
|
||||
|
||||
/// 자주 사용되는 전화번호 접두사 목록 반환
|
||||
static List<String> getCommonPhonePrefixes() {
|
||||
return [
|
||||
'010',
|
||||
'011',
|
||||
'016',
|
||||
'017',
|
||||
'018',
|
||||
'019',
|
||||
'070',
|
||||
'080',
|
||||
'02',
|
||||
'031',
|
||||
'032',
|
||||
'033',
|
||||
'041',
|
||||
'042',
|
||||
'043',
|
||||
'044',
|
||||
'051',
|
||||
'052',
|
||||
'053',
|
||||
'054',
|
||||
'055',
|
||||
'061',
|
||||
'062',
|
||||
'063',
|
||||
'064',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// 전화번호 입력 형식 지정용 TextInputFormatter (내부 전용)
|
||||
class _PhoneTextInputFormatter extends TextInputFormatter {
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
final digitsOnly = newValue.text.replaceAll(RegExp(r'[^\d]+'), '');
|
||||
final trimmed =
|
||||
digitsOnly.length > 11 ? digitsOnly.substring(0, 11) : digitsOnly;
|
||||
String formatted = '';
|
||||
if (trimmed.length > 7) {
|
||||
formatted =
|
||||
'${trimmed.substring(0, 3)}-${trimmed.substring(3, 7)}-${trimmed.substring(7)}';
|
||||
} else if (trimmed.length > 3) {
|
||||
formatted = '${trimmed.substring(0, 3)}-${trimmed.substring(3)}';
|
||||
} else {
|
||||
formatted = trimmed;
|
||||
}
|
||||
int selectionIndex =
|
||||
newValue.selection.end + (formatted.length - newValue.text.length);
|
||||
if (selectionIndex < 0) selectionIndex = 0;
|
||||
if (selectionIndex > formatted.length) selectionIndex = formatted.length;
|
||||
return TextEditingValue(
|
||||
text: formatted,
|
||||
selection: TextSelection.collapsed(offset: selectionIndex),
|
||||
);
|
||||
}
|
||||
}
|
||||
16
lib/utils/user_utils.dart
Normal file
16
lib/utils/user_utils.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
// 사용자 관련 유틸리티 함수 모음
|
||||
// 역할명 변환 등 공통 로직을 관리
|
||||
|
||||
import '../utils/constants.dart';
|
||||
|
||||
// 역할 코드 → 한글명 변환 함수
|
||||
String getRoleName(String role) {
|
||||
switch (role) {
|
||||
case UserRoles.admin:
|
||||
return '관리자';
|
||||
case UserRoles.member:
|
||||
return '일반 사용자';
|
||||
default:
|
||||
return '알 수 없음';
|
||||
}
|
||||
}
|
||||
136
lib/utils/validators.dart
Normal file
136
lib/utils/validators.dart
Normal file
@@ -0,0 +1,136 @@
|
||||
/// 폼 필드 검증 함수 및 유틸리티 (SRP, 재사용성, 테스트 용이성 중심)
|
||||
|
||||
/// 필수 입력값 검증
|
||||
String? validateRequired(String? value, String fieldName) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '$fieldName을(를) 입력해주세요';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 이메일 형식 검증
|
||||
String? validateEmail(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '이메일을 입력해주세요';
|
||||
}
|
||||
final emailRegex = RegExp(
|
||||
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
|
||||
);
|
||||
if (!emailRegex.hasMatch(value)) {
|
||||
return '유효한 이메일 주소를 입력해주세요';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 전화번호 형식 검증 (숫자, 하이픈만 허용)
|
||||
String? validatePhoneNumber(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null; // 필수 입력 아님
|
||||
}
|
||||
final phoneRegex = RegExp(r'^[0-9\-]+$');
|
||||
if (!phoneRegex.hasMatch(value)) {
|
||||
return '전화번호는 숫자와 하이픈(-)만 입력 가능합니다';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 숫자 검증
|
||||
String? validateNumber(String? value, String fieldName) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '$fieldName을(를) 입력해주세요';
|
||||
}
|
||||
final numberRegex = RegExp(r'^[0-9]+$');
|
||||
if (!numberRegex.hasMatch(value)) {
|
||||
return '$fieldName은(는) 숫자만 입력 가능합니다';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 최소 길이 검증
|
||||
String? validateMinLength(String? value, String fieldName, int minLength) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '$fieldName을(를) 입력해주세요';
|
||||
}
|
||||
if (value.length < minLength) {
|
||||
return '$fieldName은(는) 최소 $minLength자 이상이어야 합니다';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 최대 길이 검증
|
||||
String? validateMaxLength(String? value, String fieldName, int maxLength) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null; // 필수 입력 아님
|
||||
}
|
||||
if (value.length > maxLength) {
|
||||
return '$fieldName은(는) 최대 $maxLength자 이하여야 합니다';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 시리얼 넘버 검증 (알파벳, 숫자, 하이픈만 허용)
|
||||
String? validateSerialNumber(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null; // 필수 입력 아님
|
||||
}
|
||||
final serialRegex = RegExp(r'^[a-zA-Z0-9\-]+$');
|
||||
if (!serialRegex.hasMatch(value)) {
|
||||
return '시리얼 번호는 알파벳, 숫자, 하이픈(-)만 입력 가능합니다';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 바코드 검증 (숫자만 허용)
|
||||
String? validateBarcode(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null; // 필수 입력 아님
|
||||
}
|
||||
final barcodeRegex = RegExp(r'^[0-9]+$');
|
||||
if (!barcodeRegex.hasMatch(value)) {
|
||||
return '바코드는 숫자만 입력 가능합니다';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// FormValidator: 폼 필드 검증 유틸리티 클래스
|
||||
class FormValidator {
|
||||
/// 필수 입력 검증
|
||||
static String? Function(String?) required(String errorMessage) {
|
||||
return (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return errorMessage;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// 이메일 형식 검증
|
||||
static String? Function(String?) email([String? errorMessage]) {
|
||||
return (String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null; // 빈 값은 허용
|
||||
}
|
||||
final bool emailValid = RegExp(
|
||||
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
|
||||
).hasMatch(value);
|
||||
if (!emailValid) {
|
||||
return errorMessage ?? '유효한 이메일 주소를 입력하세요';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// 전화번호 형식 검증
|
||||
static String? Function(String?) phone([String? errorMessage]) {
|
||||
return (String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null; // 빈 값은 허용
|
||||
}
|
||||
final bool phoneValid = RegExp(r'^[0-9\-\s]+$').hasMatch(value);
|
||||
if (!phoneValid) {
|
||||
return errorMessage ?? '유효한 전화번호를 입력하세요';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user