Files
superport/lib/screens/company/company_list_redesign.dart
JiWoong Sul 71b7b7f40b feat: API 연동 개선 및 라이선스 모델 확장
- 라이선스 모델 전면 개편 (상세 필드 추가, 계산 필드 구현)
- API 응답 처리 개선 (HTTP 상태 코드 기반)
- 장비 출고 폼 컨트롤러 추가
- 회사 지점 정보 모델 추가
- 공통 데이터 모델 구조 추가
- 전체 서비스 레이어 API 호출 방식 통일
- UI 컴포넌트 마이너 개선
2025-07-25 01:22:15 +09:00

566 lines
23 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'dart:async';
import 'package:superport/models/company_model.dart';
import 'package:superport/screens/common/theme_shadcn.dart';
import 'package:superport/screens/common/components/shadcn_components.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/screens/company/widgets/company_branch_dialog.dart';
import 'package:superport/screens/company/controllers/company_list_controller.dart';
/// shadcn/ui 스타일로 재설계된 회사 관리 화면
class CompanyListRedesign extends StatefulWidget {
const CompanyListRedesign({super.key});
@override
State<CompanyListRedesign> createState() => _CompanyListRedesignState();
}
class _CompanyListRedesignState extends State<CompanyListRedesign> {
late CompanyListController _controller;
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
Timer? _debounceTimer;
@override
void initState() {
super.initState();
_controller = CompanyListController(dataService: MockDataService());
_controller.initialize();
_setupScrollListener();
}
@override
void dispose() {
_controller.dispose();
_scrollController.dispose();
_searchController.dispose();
_debounceTimer?.cancel();
super.dispose();
}
/// 스크롤 리스너 설정 (무한 스크롤)
void _setupScrollListener() {
_scrollController.addListener(() {
if (_scrollController.position.pixels ==
_scrollController.position.maxScrollExtent) {
_controller.loadMore();
}
});
}
/// 검색어 입력 처리 (디바운싱)
void _onSearchChanged(String value) {
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 500), () {
_controller.updateSearchKeyword(value);
});
}
/// 회사 추가 화면으로 이동
void _navigateToAddScreen() async {
final result = await Navigator.pushNamed(context, '/company/add');
if (result == true) {
_controller.refresh();
}
}
/// 회사 삭제 처리
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: () async {
Navigator.pop(context);
final success = await _controller.deleteCompany(id);
if (!success && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_controller.error ?? '삭제에 실패했습니다'),
backgroundColor: Colors.red,
),
);
}
},
child: const Text('삭제'),
),
],
),
);
}
/// 지점 다이얼로그 표시
void _showBranchDialog(Company mainCompany) {
showDialog(
context: context,
builder: (context) => CompanyBranchDialog(mainCompany: mainCompany),
);
}
/// Branch 객체를 Company 객체로 변환
Company _convertBranchToCompany(Branch branch) {
return 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,
);
}
/// 회사 유형 배지 생성
Widget _buildCompanyTypeChips(List<CompanyType> types) {
return Wrap(
spacing: ShadcnTheme.spacing1,
children:
types.map((type) {
return ShadcnBadge(
text: companyTypeToString(type),
variant:
type == CompanyType.customer
? ShadcnBadgeVariant.primary
: ShadcnBadgeVariant.secondary,
size: ShadcnBadgeSize.small,
);
}).toList(),
);
}
/// 본사/지점 구분 배지 생성
Widget _buildCompanyTypeLabel(bool isBranch) {
return ShadcnBadge(
text: isBranch ? '지점' : '본사',
variant:
isBranch ? ShadcnBadgeVariant.outline : ShadcnBadgeVariant.primary,
size: ShadcnBadgeSize.small,
);
}
/// 회사 이름 표시 (지점인 경우 본사명 포함)
Widget _buildCompanyNameText(
Company company,
bool isBranch, {
String? mainCompanyName,
}) {
if (isBranch && mainCompanyName != null) {
return Text.rich(
TextSpan(
children: [
TextSpan(text: '$mainCompanyName > ', style: ShadcnTheme.bodyMuted),
TextSpan(text: company.name, style: ShadcnTheme.bodyMedium),
],
),
);
} else {
return Text(company.name, style: ShadcnTheme.bodyMedium);
}
}
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: _controller,
child: Consumer<CompanyListController>(
builder: (context, controller, child) {
// 본사와 지점 구분하기 위한 데이터 준비
final List<Map<String, dynamic>> displayCompanies = [];
for (final company in controller.filteredCompanies) {
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,
'isBranch': true,
'mainCompanyName': company.name,
});
}
}
}
final int totalCount = displayCompanies.length;
return SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.all(ShadcnTheme.spacing6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 헤더 및 검색 바
Row(
children: [
Expanded(
child: Container(
height: 40,
decoration: BoxDecoration(
color: ShadcnTheme.card,
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
border: Border.all(color: ShadcnTheme.border),
),
child: TextField(
controller: _searchController,
onChanged: _onSearchChanged,
decoration: InputDecoration(
hintText: '회사명, 담당자명, 연락처로 검색',
hintStyle: TextStyle(color: ShadcnTheme.muted),
prefixIcon: Icon(Icons.search, color: ShadcnTheme.muted),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
),
),
),
const SizedBox(width: ShadcnTheme.spacing4),
ShadcnButton(
text: '회사 추가',
onPressed: _navigateToAddScreen,
variant: ShadcnButtonVariant.primary,
textColor: Colors.white,
icon: Icon(Icons.add),
),
],
),
const SizedBox(height: ShadcnTheme.spacing4),
// 결과 정보
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('$totalCount개 회사', style: ShadcnTheme.bodyMuted),
if (controller.searchKeyword.isNotEmpty)
Text(
'"${controller.searchKeyword}" 검색 결과',
style: ShadcnTheme.bodyMuted,
),
],
),
const SizedBox(height: ShadcnTheme.spacing4),
// 에러 메시지
if (controller.error != null)
Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing4),
margin: const EdgeInsets.only(bottom: ShadcnTheme.spacing4),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
border: Border.all(color: Colors.red.shade200),
),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red),
const SizedBox(width: ShadcnTheme.spacing2),
Expanded(
child: Text(
controller.error!,
style: TextStyle(color: Colors.red.shade700),
),
),
IconButton(
icon: Icon(Icons.close, size: 16),
onPressed: controller.clearError,
padding: EdgeInsets.zero,
constraints: BoxConstraints(maxHeight: 24, maxWidth: 24),
),
],
),
),
// 테이블 카드
Container(
width: double.infinity,
decoration: BoxDecoration(
color: ShadcnTheme.card,
borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg),
border: Border.all(color: ShadcnTheme.border),
boxShadow: ShadcnTheme.cardShadow,
),
child: controller.isLoading && controller.companies.isEmpty
? Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing8),
child: Center(
child: CircularProgressIndicator(),
),
)
: displayCompanies.isEmpty
? Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing8),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.business_outlined,
size: 48,
color: ShadcnTheme.muted,
),
const SizedBox(height: ShadcnTheme.spacing4),
Text(
controller.searchKeyword.isNotEmpty
? '검색 결과가 없습니다'
: '등록된 회사가 없습니다',
style: ShadcnTheme.bodyMuted,
),
],
),
),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 테이블 헤더
Container(
padding: const EdgeInsets.symmetric(
horizontal: ShadcnTheme.spacing4,
vertical: ShadcnTheme.spacing3,
),
decoration: BoxDecoration(
color: ShadcnTheme.muted.withValues(alpha: 0.3),
border: Border(
bottom: BorderSide(color: ShadcnTheme.border),
),
),
child: Row(
children: [
Expanded(
flex: 1,
child: Text(
'번호',
style: ShadcnTheme.bodyMedium,
),
),
Expanded(
flex: 3,
child: Text(
'회사명',
style: ShadcnTheme.bodyMedium,
),
),
Expanded(
flex: 2,
child: Text(
'구분',
style: ShadcnTheme.bodyMedium,
),
),
Expanded(
flex: 2,
child: Text(
'유형',
style: ShadcnTheme.bodyMedium,
),
),
Expanded(
flex: 2,
child: Text(
'연락처',
style: ShadcnTheme.bodyMedium,
),
),
Expanded(
flex: 2,
child: Text(
'관리',
style: ShadcnTheme.bodyMedium,
),
),
],
),
),
// 테이블 데이터
...displayCompanies.asMap().entries.map((entry) {
final int index = entry.key;
final companyData = entry.value;
final bool isBranch = companyData['isBranch'] as bool;
final Company company =
isBranch
? _convertBranchToCompany(companyData['branch'] as Branch)
: companyData['company'] as Company;
final String? mainCompanyName =
companyData['mainCompanyName'] as String?;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: ShadcnTheme.spacing4,
vertical: ShadcnTheme.spacing3,
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: ShadcnTheme.border),
),
),
child: Row(
children: [
// 번호
Expanded(
flex: 1,
child: Text(
'${index + 1}',
style: ShadcnTheme.bodySmall,
),
),
// 회사명
Expanded(
flex: 3,
child: _buildCompanyNameText(
company,
isBranch,
mainCompanyName: mainCompanyName,
),
),
// 구분
Expanded(
flex: 2,
child: _buildCompanyTypeLabel(isBranch),
),
// 유형
Expanded(
flex: 2,
child: _buildCompanyTypeChips(
company.companyTypes,
),
),
// 연락처
Expanded(
flex: 2,
child: Text(
company.contactPhone ?? '-',
style: ShadcnTheme.bodySmall,
),
),
// 관리
Expanded(
flex: 2,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!isBranch &&
company.branches != null &&
company.branches!.isNotEmpty)
ShadcnButton(
text: '지점보기',
onPressed:
() => _showBranchDialog(company),
variant:
ShadcnButtonVariant.secondary,
size: ShadcnButtonSize.small,
),
if (!isBranch &&
company.branches != null &&
company.branches!.isNotEmpty)
const SizedBox(
width: ShadcnTheme.spacing2,
),
ShadcnButton(
text: '수정',
onPressed: company.id != null
? () {
if (isBranch) {
Navigator.pushNamed(
context,
'/company/edit',
arguments: {
'companyId': companyData['companyId'],
'isBranch': true,
'mainCompanyName': mainCompanyName,
'branchId': company.id,
},
).then((result) {
if (result == true) controller.refresh();
});
} else {
Navigator.pushNamed(
context,
'/company/edit',
arguments: {
'companyId': company.id,
'isBranch': false,
},
).then((result) {
if (result == true) controller.refresh();
});
}
}
: null,
variant: ShadcnButtonVariant.secondary,
size: ShadcnButtonSize.small,
),
const SizedBox(
width: ShadcnTheme.spacing2,
),
ShadcnButton(
text: '삭제',
onPressed:
(!isBranch && company.id != null)
? () =>
_deleteCompany(company.id!)
: null,
variant:
ShadcnButtonVariant.destructive,
size: ShadcnButtonSize.small,
),
],
),
),
],
),
);
}),
],
),
),
// 무한 스크롤 로딩 인디케이터
if (controller.isLoading && controller.companies.isNotEmpty)
Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing4),
child: Center(
child: CircularProgressIndicator(),
),
),
// 더 이상 로드할 데이터가 없을 때 메시지
if (!controller.hasMore && controller.companies.isNotEmpty)
Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing4),
child: Center(
child: Text(
'모든 회사를 불러왔습니다',
style: ShadcnTheme.bodyMuted,
),
),
),
],
),
);
},
),
);
}
}