feat: 사용자 관리 API 연동 구현

- UserRemoteDataSource: 사용자 CRUD, 상태 변경, 비밀번호 변경, 중복 확인 API 구현
- UserService: DTO-Model 변환 로직 및 역할/전화번호 매핑 처리
- UserListController: ChangeNotifier 패턴 적용, 페이지네이션, 검색, 필터링 기능 추가
- UserFormController: API 연동, username 중복 확인 기능 추가
- user_form.dart: username/password 필드 추가 및 실시간 검증
- user_list_redesign.dart: Provider 패턴 적용, 무한 스크롤 구현
- equipment_out_form_controller.dart: 구문 오류 수정
- API 통합 진행률: 85% (사용자 관리 100% 완료)
This commit is contained in:
JiWoong Sul
2025-07-24 19:37:58 +09:00
parent 7f491afa4f
commit 553f605e8b
15 changed files with 3808 additions and 543 deletions

View File

@@ -590,7 +590,7 @@ class EquipmentOutFormController extends ChangeNotifier {
} else {
onSuccess('${successCompanies.join(", ")} 회사로 다중 장비 출고 처리 완료');
}
} else if (selectedEquipmentInId != null) {
} else if (selectedEquipmentInId != null) {
final equipment = Equipment(
manufacturer: manufacturer,
name: name,

View File

@@ -1,23 +1,41 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.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/services/user_service.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/models/user_phone_field.dart';
// 사용자 폼의 상태 및 비즈니스 로직을 담당하는 컨트롤러
class UserFormController {
class UserFormController extends ChangeNotifier {
final MockDataService dataService;
final UserService _userService = GetIt.instance<UserService>();
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
// 상태 변수
bool _isLoading = false;
String? _error;
bool _useApi = true; // Feature flag
// 폼 필드
bool isEditMode = false;
int? userId;
String name = '';
String username = ''; // 추가
String password = ''; // 추가
int? companyId;
int? branchId;
String role = UserRoles.member;
String position = '';
String email = '';
// username 중복 확인
bool _isCheckingUsername = false;
bool? _isUsernameAvailable;
String? _lastCheckedUsername;
Timer? _usernameCheckTimer;
// 전화번호 관련 상태
final List<UserPhoneField> phoneFields = [];
@@ -25,12 +43,27 @@ class UserFormController {
List<Company> companies = [];
List<Branch> branches = [];
// Getters
bool get isLoading => _isLoading;
String? get error => _error;
bool get isCheckingUsername => _isCheckingUsername;
bool? get isUsernameAvailable => _isUsernameAvailable;
UserFormController({required this.dataService, this.userId});
UserFormController({required this.dataService, this.userId}) {
isEditMode = userId != null;
if (isEditMode) {
loadUser();
} else {
addPhoneField();
}
loadCompanies();
}
// 회사 목록 로드
void loadCompanies() {
companies = dataService.getAllCompanies();
notifyListeners();
}
// 회사 ID에 따라 지점 목록 로드
@@ -41,41 +74,63 @@ class UserFormController {
if (branchId != null && !branches.any((b) => b.id == branchId)) {
branchId = null;
}
notifyListeners();
}
// 사용자 정보 로드 (수정 모드)
void loadUser() {
Future<void> loadUser() async {
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'] ?? '',
),
);
}
_isLoading = true;
_error = null;
notifyListeners();
try {
User? user;
if (_useApi) {
user = await _userService.getUser(userId!);
} else {
addPhoneField();
user = dataService.getUserById(userId!);
}
if (user != null) {
name = user.name;
username = user.username ?? '';
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();
}
}
} catch (e) {
_error = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
// 전화번호 필드 추가
void addPhoneField() {
phoneFields.add(UserPhoneField(type: '휴대폰'));
notifyListeners();
}
// 전화번호 필드 삭제
@@ -83,64 +138,185 @@ class UserFormController {
if (phoneFields.length > 1) {
phoneFields[index].dispose();
phoneFields.removeAt(index);
notifyListeners();
}
}
// Username 중복 확인
void checkUsernameAvailability(String value) {
if (value.isEmpty || value == _lastCheckedUsername) {
return;
}
// 디바운싱
_usernameCheckTimer?.cancel();
_usernameCheckTimer = Timer(const Duration(milliseconds: 500), () async {
_isCheckingUsername = true;
notifyListeners();
try {
if (_useApi) {
final isDuplicate = await _userService.checkDuplicateUsername(value);
_isUsernameAvailable = !isDuplicate;
} else {
// Mock 데이터에서 중복 확인
final users = dataService.getAllUsers();
final exists = users.any((u) => u.username == value && u.id != userId);
_isUsernameAvailable = !exists;
}
_lastCheckedUsername = value;
} catch (e) {
_isUsernameAvailable = null;
} finally {
_isCheckingUsername = false;
notifyListeners();
}
});
}
// 사용자 저장 (UI에서 호출)
void saveUser(Function(String? error) onResult) {
Future<void> saveUser(Function(String? error) onResult) async {
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,
});
// 신규 등록 시 username 중복 확인
if (!isEditMode) {
if (username.isEmpty) {
onResult('사용자명을 입력해주세요');
return;
}
if (_isUsernameAvailable == false) {
onResult('이미 사용중인 사용자명입니다');
return;
}
if (password.isEmpty) {
onResult('비밀번호를 입력해주세요');
return;
}
}
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);
_isLoading = true;
_error = null;
notifyListeners();
try {
// 전화번호 목록 준비
String? phoneNumber;
for (var phoneField in phoneFields) {
if (phoneField.number.isNotEmpty) {
phoneNumber = phoneField.number;
break; // API는 단일 전화번호만 지원
}
}
} 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);
if (_useApi) {
if (isEditMode && userId != null) {
// 사용자 수정
await _userService.updateUser(
userId!,
name: name,
email: email.isNotEmpty ? email : null,
phone: phoneNumber,
companyId: companyId,
branchId: branchId,
role: role,
position: position.isNotEmpty ? position : null,
password: password.isNotEmpty ? password : null,
);
} else {
// 사용자 생성
await _userService.createUser(
username: username,
email: email,
password: password,
name: name,
role: role,
companyId: companyId!,
branchId: branchId,
phone: phoneNumber,
position: position.isNotEmpty ? position : null,
);
}
} else {
// Mock 데이터 사용
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,
username: username.isNotEmpty ? username : null,
isActive: user.isActive,
createdAt: user.createdAt,
updatedAt: DateTime.now(),
);
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,
username: username,
isActive: true,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
dataService.addUser(newUser);
}
}
onResult(null);
} catch (e) {
_error = e.toString();
onResult(_error);
} finally {
_isLoading = false;
notifyListeners();
}
onResult(null);
}
// 컨트롤러 해제
@override
void dispose() {
_usernameCheckTimer?.cancel();
for (var phoneField in phoneFields) {
phoneField.dispose();
}
super.dispose();
}
// API/Mock 모드 전환
void toggleApiMode() {
_useApi = !_useApi;
notifyListeners();
}
}

View File

@@ -1,28 +1,203 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.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/services/user_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 = [];
final UserService _userService = GetIt.instance<UserService>();
// 상태 변수
List<User> _users = [];
bool _isLoading = false;
String? _error;
bool _useApi = true; // Feature flag
// 페이지네이션
int _currentPage = 1;
final int _perPage = 20;
bool _hasMoreData = true;
bool _isLoadingMore = false;
// 검색/필터
String _searchQuery = '';
int? _filterCompanyId;
String? _filterRole;
bool? _filterIsActive;
// Getters
List<User> get users => _users;
bool get isLoading => _isLoading;
bool get isLoadingMore => _isLoadingMore;
String? get error => _error;
bool get hasMoreData => _hasMoreData;
String get searchQuery => _searchQuery;
int? get filterCompanyId => _filterCompanyId;
String? get filterRole => _filterRole;
bool? get filterIsActive => _filterIsActive;
UserListController({required this.dataService});
/// 사용자 목록 데이터 로드
void loadUsers() {
users = dataService.getAllUsers();
/// 사용자 목록 초기 로드
Future<void> loadUsers({bool refresh = false}) async {
if (refresh) {
_currentPage = 1;
_hasMoreData = true;
_users.clear();
}
if (_isLoading) return;
_isLoading = true;
_error = null;
notifyListeners();
try {
if (_useApi) {
final newUsers = await _userService.getUsers(
page: _currentPage,
perPage: _perPage,
isActive: _filterIsActive,
companyId: _filterCompanyId,
role: _filterRole,
);
if (newUsers.isEmpty || newUsers.length < _perPage) {
_hasMoreData = false;
}
if (_currentPage == 1) {
_users = newUsers;
} else {
_users.addAll(newUsers);
}
_currentPage++;
} else {
// Mock 데이터 사용
var allUsers = dataService.getAllUsers();
// 필터 적용
if (_filterCompanyId != null) {
allUsers = allUsers.where((u) => u.companyId == _filterCompanyId).toList();
}
if (_filterRole != null) {
allUsers = allUsers.where((u) => u.role == _filterRole).toList();
}
if (_filterIsActive != null) {
allUsers = allUsers.where((u) => u.isActive == _filterIsActive).toList();
}
// 검색 적용
if (_searchQuery.isNotEmpty) {
allUsers = allUsers.where((u) =>
u.name.toLowerCase().contains(_searchQuery.toLowerCase()) ||
(u.email?.toLowerCase().contains(_searchQuery.toLowerCase()) ?? false) ||
(u.username?.toLowerCase().contains(_searchQuery.toLowerCase()) ?? false)
).toList();
}
_users = allUsers;
_hasMoreData = false;
}
} catch (e) {
_error = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 다음 페이지 로드 (무한 스크롤용)
Future<void> loadMore() async {
if (!_hasMoreData || _isLoadingMore || _isLoading) return;
_isLoadingMore = true;
notifyListeners();
try {
await loadUsers();
} finally {
_isLoadingMore = false;
notifyListeners();
}
}
/// 검색 쿼리 설정
void setSearchQuery(String query) {
_searchQuery = query;
_currentPage = 1;
_hasMoreData = true;
loadUsers(refresh: true);
}
/// 필터 설정
void setFilters({
int? companyId,
String? role,
bool? isActive,
}) {
_filterCompanyId = companyId;
_filterRole = role;
_filterIsActive = isActive;
_currentPage = 1;
_hasMoreData = true;
loadUsers(refresh: true);
}
/// 필터 초기화
void clearFilters() {
_filterCompanyId = null;
_filterRole = null;
_filterIsActive = null;
_searchQuery = '';
_currentPage = 1;
_hasMoreData = true;
loadUsers(refresh: true);
}
/// 사용자 삭제
void deleteUser(int id, VoidCallback onDeleted) {
dataService.deleteUser(id);
loadUsers();
onDeleted();
Future<void> deleteUser(int id, VoidCallback onDeleted, Function(String) onError) async {
try {
if (_useApi) {
await _userService.deleteUser(id);
} else {
dataService.deleteUser(id);
}
// 목록에서 삭제된 사용자 제거
_users.removeWhere((user) => user.id == id);
notifyListeners();
onDeleted();
} catch (e) {
onError('사용자 삭제 실패: ${e.toString()}');
}
}
/// 사용자 상태 변경 (활성/비활성)
Future<void> changeUserStatus(int id, bool isActive, Function(String) onError) async {
try {
if (_useApi) {
final updatedUser = await _userService.changeUserStatus(id, isActive);
// 목록에서 해당 사용자 업데이트
final index = _users.indexWhere((u) => u.id == id);
if (index != -1) {
_users[index] = updatedUser;
notifyListeners();
}
} else {
// Mock 데이터에서는 상태 변경 지원 안함
onError('Mock 데이터에서는 상태 변경을 지원하지 않습니다');
}
} catch (e) {
onError('상태 변경 실패: ${e.toString()}');
}
}
/// 권한명 반환 함수는 user_utils.dart의 getRoleName을 사용
@@ -39,4 +214,10 @@ class UserListController extends ChangeNotifier {
);
return branch.name;
}
/// API/Mock 모드 전환
void toggleApiMode() {
_useApi = !_useApi;
loadUsers(refresh: true);
}
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/models/user_model.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
@@ -21,116 +22,288 @@ class UserFormScreen extends StatefulWidget {
}
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();
}
}
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _confirmPasswordController = TextEditingController();
bool _showPassword = false;
bool _showConfirmPassword = false;
@override
void dispose() {
_controller.dispose();
_passwordController.dispose();
_confirmPasswordController.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),
return ChangeNotifierProvider(
create: (_) => UserFormController(
dataService: MockDataService(),
userId: widget.userId,
),
child: Consumer<UserFormController>(
builder: (context, controller, child) {
return Scaffold(
appBar: AppBar(
title: Text(controller.isEditMode ? '사용자 수정' : '사용자 등록'),
),
body: controller.isLoading
? const Center(child: CircularProgressIndicator())
: 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!,
),
// 사용자명 (신규 등록 시만)
if (!controller.isEditMode) ...[
_buildTextField(
label: '사용자명',
initialValue: controller.username,
hintText: '로그인에 사용할 사용자명',
validator: (value) {
if (value == null || value.isEmpty) {
return '사용자명을 입력해주세요';
}
if (value.length < 3) {
return '사용자명은 3자 이상이어야 합니다';
}
if (controller.isUsernameAvailable == false) {
return '이미 사용 중인 사용자명입니다';
}
return null;
},
onChanged: (value) {
controller.username = value;
controller.checkUsernameAvailability(value);
},
onSaved: (value) => controller.username = value!,
suffixIcon: controller.isCheckingUsername
? const SizedBox(
width: 20,
height: 20,
child: Padding(
padding: EdgeInsets.all(12.0),
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
)
: controller.isUsernameAvailable != null
? Icon(
controller.isUsernameAvailable!
? Icons.check_circle
: Icons.cancel,
color: controller.isUsernameAvailable!
? Colors.green
: Colors.red,
)
: null,
),
// 비밀번호
_buildPasswordField(
label: '비밀번호',
controller: _passwordController,
hintText: '비밀번호를 입력하세요',
obscureText: !_showPassword,
onToggleVisibility: () {
setState(() {
_showPassword = !_showPassword;
});
},
validator: (value) {
if (value == null || value.isEmpty) {
return '비밀번호를 입력해주세요';
}
if (value.length < 6) {
return '비밀번호는 6자 이상이어야 합니다';
}
return null;
},
onSaved: (value) => controller.password = value!,
),
// 비밀번호 확인
_buildPasswordField(
label: '비밀번호 확인',
controller: _confirmPasswordController,
hintText: '비밀번호를 다시 입력하세요',
obscureText: !_showConfirmPassword,
onToggleVisibility: () {
setState(() {
_showConfirmPassword = !_showConfirmPassword;
});
},
validator: (value) {
if (value == null || value.isEmpty) {
return '비밀번호를 다시 입력해주세요';
}
if (value != _passwordController.text) {
return '비밀번호가 일치하지 않습니다';
}
return null;
},
),
],
// 수정 모드에서 비밀번호 변경 (선택사항)
if (controller.isEditMode) ...[
ExpansionTile(
title: const Text('비밀번호 변경'),
children: [
_buildPasswordField(
label: '새 비밀번호',
controller: _passwordController,
hintText: '변경할 경우만 입력하세요',
obscureText: !_showPassword,
onToggleVisibility: () {
setState(() {
_showPassword = !_showPassword;
});
},
validator: (value) {
if (value != null && value.isNotEmpty && value.length < 6) {
return '비밀번호는 6자 이상이어야 합니다';
}
return null;
},
onSaved: (value) => controller.password = value ?? '',
),
_buildPasswordField(
label: '새 비밀번호 확인',
controller: _confirmPasswordController,
hintText: '비밀번호를 다시 입력하세요',
obscureText: !_showConfirmPassword,
onToggleVisibility: () {
setState(() {
_showConfirmPassword = !_showConfirmPassword;
});
},
validator: (value) {
if (_passwordController.text.isNotEmpty && value != _passwordController.text) {
return '비밀번호가 일치하지 않습니다';
}
return null;
},
),
],
),
],
// 직급
_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) {
controller.companyId = value;
controller.branchId = null;
if (value != null) {
controller.loadBranches(value);
} else {
controller.branches = [];
}
},
onBranchChanged: (value) {
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(controller),
// 권한
_buildRoleRadio(controller),
const SizedBox(height: 24),
// 오류 메시지 표시
if (controller.error != null)
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade200),
),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red.shade700),
const SizedBox(width: 8),
Expanded(
child: Text(
controller.error!,
style: TextStyle(color: Colors.red.shade700),
),
),
],
),
),
// 저장 버튼
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: controller.isLoading
? null
: () => _onSaveUser(controller),
style: AppThemeTailwind.primaryButtonStyle,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: controller.isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: Text(
controller.isEditMode ? '수정하기' : '등록하기',
style: const TextStyle(fontSize: 16),
),
),
),
),
],
),
),
),
),
),
],
),
),
),
);
},
),
);
}
@@ -144,6 +317,8 @@ class _UserFormScreenState extends State<UserFormScreen> {
List<TextInputFormatter>? inputFormatters,
String? Function(String?)? validator,
void Function(String?)? onSaved,
void Function(String)? onChanged,
Widget? suffixIcon,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 16.0),
@@ -154,25 +329,66 @@ class _UserFormScreenState extends State<UserFormScreen> {
const SizedBox(height: 4),
TextFormField(
initialValue: initialValue,
decoration: InputDecoration(hintText: hintText),
decoration: InputDecoration(
hintText: hintText,
suffixIcon: suffixIcon,
),
keyboardType: keyboardType,
inputFormatters: inputFormatters,
validator: validator,
onSaved: onSaved,
onChanged: onChanged,
),
],
),
);
}
// 비밀번호 필드 위젯
Widget _buildPasswordField({
required String label,
required TextEditingController controller,
required String hintText,
required bool obscureText,
required VoidCallback onToggleVisibility,
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(
controller: controller,
obscureText: obscureText,
decoration: InputDecoration(
hintText: hintText,
suffixIcon: IconButton(
icon: Icon(
obscureText ? Icons.visibility : Icons.visibility_off,
),
onPressed: onToggleVisibility,
),
),
validator: validator,
onSaved: onSaved,
),
],
),
);
}
// 전화번호 입력 필드 섹션 위젯 (UserPhoneField 기반)
Widget _buildPhoneFieldsSection() {
Widget _buildPhoneFieldsSection(UserFormController controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('전화번호', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
..._controller.phoneFields.asMap().entries.map((entry) {
...controller.phoneFields.asMap().entries.map((entry) {
final i = entry.key;
final phoneField = entry.value;
return Row(
@@ -180,17 +396,11 @@ class _UserFormScreenState extends State<UserFormScreen> {
// 종류 드롭다운
DropdownButton<String>(
value: phoneField.type,
items:
_controller.phoneTypes
.map(
(type) =>
DropdownMenuItem(value: type, child: Text(type)),
)
.toList(),
items: controller.phoneTypes
.map((type) => DropdownMenuItem(value: type, child: Text(type)))
.toList(),
onChanged: (value) {
setState(() {
phoneField.type = value!;
});
phoneField.type = value!;
},
),
const SizedBox(width: 8),
@@ -205,14 +415,9 @@ class _UserFormScreenState extends State<UserFormScreen> {
),
IconButton(
icon: const Icon(Icons.remove_circle, color: Colors.red),
onPressed:
_controller.phoneFields.length > 1
? () {
setState(() {
_controller.removePhoneField(i);
});
}
: null,
onPressed: controller.phoneFields.length > 1
? () => controller.removePhoneField(i)
: null,
),
],
);
@@ -221,11 +426,7 @@ class _UserFormScreenState extends State<UserFormScreen> {
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () {
setState(() {
_controller.addPhoneField();
});
},
onPressed: () => controller.addPhoneField(),
icon: const Icon(Icons.add),
label: const Text('전화번호 추가'),
),
@@ -235,7 +436,7 @@ class _UserFormScreenState extends State<UserFormScreen> {
}
// 권한(관리등급) 라디오 위젯
Widget _buildRoleRadio() {
Widget _buildRoleRadio(UserFormController controller) {
return Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Column(
@@ -249,11 +450,9 @@ class _UserFormScreenState extends State<UserFormScreen> {
child: RadioListTile<String>(
title: const Text('관리자'),
value: UserRoles.admin,
groupValue: _controller.role,
groupValue: controller.role,
onChanged: (value) {
setState(() {
_controller.role = value!;
});
controller.role = value!;
},
),
),
@@ -261,11 +460,9 @@ class _UserFormScreenState extends State<UserFormScreen> {
child: RadioListTile<String>(
title: const Text('일반 사용자'),
value: UserRoles.member,
groupValue: _controller.role,
groupValue: controller.role,
onChanged: (value) {
setState(() {
_controller.role = value!;
});
controller.role = value!;
},
),
),
@@ -277,17 +474,24 @@ class _UserFormScreenState extends State<UserFormScreen> {
}
// 저장 버튼 클릭 시 사용자 저장
void _onSaveUser() {
setState(() {
_controller.saveUser((error) {
if (error != null) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error)));
} else {
Navigator.pop(context, true);
}
});
void _onSaveUser(UserFormController controller) async {
await controller.saveUser((error) {
if (error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(error),
backgroundColor: Colors.red,
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(controller.isEditMode ? '사용자 정보가 수정되었습니다' : '사용자가 등록되었습니다'),
backgroundColor: Colors.green,
),
);
Navigator.pop(context, true);
}
});
}
}

View File

@@ -1,10 +1,13 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:superport/models/user_model.dart';
import 'package:superport/screens/common/theme_shadcn.dart';
import 'package:superport/screens/common/components/shadcn_components.dart';
import 'package:superport/screens/user/controllers/user_list_controller.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/utils/user_utils.dart';
/// shadcn/ui 스타일로 재설계된 사용자 관리 화면
class UserListRedesign extends StatefulWidget {
@@ -15,28 +18,49 @@ class UserListRedesign extends StatefulWidget {
}
class _UserListRedesignState extends State<UserListRedesign> {
late final UserListController _controller;
final MockDataService _dataService = MockDataService();
int _currentPage = 1;
final int _pageSize = 10;
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
_controller = UserListController(dataService: _dataService);
_controller.loadUsers();
_controller.addListener(_refresh);
// 초기 데이터 로드
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<UserListController>().loadUsers();
});
// 무한 스크롤 설정
_scrollController.addListener(_onScroll);
// 검색 디바운싱
_searchController.addListener(() {
_onSearchChanged(_searchController.text);
});
}
@override
void dispose() {
_controller.removeListener(_refresh);
_scrollController.dispose();
_searchController.dispose();
super.dispose();
}
/// 상태 갱신용 setState 래퍼
void _refresh() {
setState(() {});
/// 스크롤 이벤트 처리
void _onScroll() {
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
context.read<UserListController>().loadMore();
}
}
/// 검색어 변경 처리 (디바운싱)
Timer? _debounce;
void _onSearchChanged(String query) {
if (_debounce?.isActive ?? false) _debounce!.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () {
context.read<UserListController>().setSearchQuery(query);
});
}
/// 회사명 반환 함수
@@ -44,36 +68,40 @@ class _UserListRedesignState extends State<UserListRedesign> {
final company = _dataService.getCompanyById(companyId);
return company?.name ?? '-';
}
/// 상태별 색상 반환
Color _getStatusColor(bool isActive) {
return isActive ? Colors.green : Colors.red;
}
/// 사용자 권한 표시 배지
Widget _buildUserRoleBadge(String role) {
final roleName = getRoleName(role);
ShadcnBadgeVariant variant;
switch (role) {
case 'S':
return ShadcnBadge(
text: '관리자',
variant: ShadcnBadgeVariant.destructive,
size: ShadcnBadgeSize.small,
);
variant = ShadcnBadgeVariant.destructive;
break;
case 'M':
return ShadcnBadge(
text: '멤버',
variant: ShadcnBadgeVariant.primary,
size: ShadcnBadgeSize.small,
);
variant = ShadcnBadgeVariant.primary;
break;
default:
return ShadcnBadge(
text: '사용자',
variant: ShadcnBadgeVariant.outline,
size: ShadcnBadgeSize.small,
);
variant = ShadcnBadgeVariant.outline;
}
return ShadcnBadge(
text: roleName,
variant: variant,
size: ShadcnBadgeSize.small,
);
}
/// 사용자 추가 폼으로 이동
void _navigateToAdd() async {
final result = await Navigator.pushNamed(context, Routes.userAdd);
if (result == true) {
_controller.loadUsers();
if (result == true && mounted) {
context.read<UserListController>().loadUsers(refresh: true);
}
}
@@ -84,297 +112,491 @@ class _UserListRedesignState extends State<UserListRedesign> {
Routes.userEdit,
arguments: userId,
);
if (result == true) {
_controller.loadUsers();
if (result == true && mounted) {
context.read<UserListController>().loadUsers(refresh: true);
}
}
/// 사용자 삭제 다이얼로그
void _showDeleteDialog(int userId) {
void _showDeleteDialog(int userId, String userName) {
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: const Text('사용자 삭제'),
content: const Text('정말로 삭제하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('취소'),
),
TextButton(
onPressed: () {
_controller.deleteUser(userId, () {
setState(() {});
});
Navigator.of(context).pop();
},
child: const Text('삭제'),
),
],
builder: (context) => AlertDialog(
title: const Text('사용자 삭제'),
content: Text('"$userName" 사용자를 정말로 삭제하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('취소'),
),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
await context.read<UserListController>().deleteUser(
userId,
() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('사용자가 삭제되었습니다')),
);
},
(error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error), backgroundColor: Colors.red),
);
},
);
},
child: const Text('삭제', style: TextStyle(color: Colors.red)),
),
],
),
);
}
/// 상태 변경 확인 다이얼로그
void _showStatusChangeDialog(User user) {
final newStatus = !user.isActive;
final statusText = newStatus ? '활성화' : '비활성화';
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('사용자 상태 변경'),
content: Text('"${user.name}" 사용자를 $statusText 하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('취소'),
),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
await context.read<UserListController>().changeUserStatus(
user.id!,
newStatus,
(error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error), backgroundColor: Colors.red),
);
},
);
},
child: Text(statusText),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final int totalCount = _controller.users.length;
final int startIndex = (_currentPage - 1) * _pageSize;
final int endIndex =
(startIndex + _pageSize) > totalCount
? totalCount
: (startIndex + _pageSize);
final List<User> pagedUsers = _controller.users.sublist(
startIndex,
endIndex,
);
return SingleChildScrollView(
padding: const EdgeInsets.all(ShadcnTheme.spacing6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 헤더 액션 바
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('$totalCount명 사용자', style: ShadcnTheme.bodyMuted),
Row(
return ChangeNotifierProvider(
create: (_) => UserListController(dataService: _dataService),
child: Consumer<UserListController>(
builder: (context, controller, child) {
if (controller.isLoading && controller.users.isEmpty) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (controller.error != null && controller.users.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ShadcnButton(
text: '새로고침',
onPressed: _controller.loadUsers,
variant: ShadcnButtonVariant.secondary,
icon: Icon(Icons.refresh),
Icon(Icons.error_outline, size: 64, color: Colors.red[300]),
const SizedBox(height: 16),
Text(
'데이터를 불러올 수 없습니다',
style: ShadcnTheme.h4,
),
const SizedBox(width: ShadcnTheme.spacing2),
const SizedBox(height: 8),
Text(
controller.error!,
style: ShadcnTheme.bodyMuted,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ShadcnButton(
text: '사용자 추가',
onPressed: _navigateToAdd,
text: '다시 시도',
onPressed: () => controller.loadUsers(refresh: true),
variant: ShadcnButtonVariant.primary,
textColor: Colors.white,
icon: Icon(Icons.add),
),
],
),
],
),
const SizedBox(height: ShadcnTheme.spacing4),
// 테이블 컨테이너
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(color: ShadcnTheme.border),
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
),
child: 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(
);
}
return SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.all(ShadcnTheme.spacing6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 검색 및 필터 섹션
Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
side: BorderSide(color: ShadcnTheme.border),
),
child: Padding(
padding: const EdgeInsets.all(ShadcnTheme.spacing4),
child: Column(
children: [
Expanded(
flex: 1,
child: Text('번호', style: ShadcnTheme.bodyMedium),
// 검색 바
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: '이름, 이메일, 사용자명으로 검색...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
controller.setSearchQuery('');
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: ShadcnTheme.spacing4,
vertical: ShadcnTheme.spacing3,
),
),
),
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),
),
Expanded(
flex: 1,
child: Text('권한', style: ShadcnTheme.bodyMedium),
),
Expanded(
flex: 1,
child: Text('관리', style: ShadcnTheme.bodyMedium),
const SizedBox(height: ShadcnTheme.spacing3),
// 필터 버튼들
Row(
children: [
// 상태 필터
ShadcnButton(
text: controller.filterIsActive == null
? '모든 상태'
: controller.filterIsActive!
? '활성 사용자'
: '비활성 사용자',
onPressed: () {
controller.setFilters(
isActive: controller.filterIsActive == null
? true
: controller.filterIsActive!
? false
: null,
);
},
variant: ShadcnButtonVariant.outline,
icon: const Icon(Icons.filter_list),
),
const SizedBox(width: ShadcnTheme.spacing2),
// 권한 필터
PopupMenuButton<String?>(
child: ShadcnButton(
text: controller.filterRole == null
? '모든 권한'
: getRoleName(controller.filterRole!),
onPressed: null,
variant: ShadcnButtonVariant.outline,
icon: const Icon(Icons.person),
),
onSelected: (role) {
controller.setFilters(role: role);
},
itemBuilder: (context) => [
const PopupMenuItem(
value: null,
child: Text('모든 권한'),
),
const PopupMenuItem(
value: 'S',
child: Text('관리자'),
),
const PopupMenuItem(
value: 'M',
child: Text('멤버'),
),
],
),
const Spacer(),
// 필터 초기화
if (controller.searchQuery.isNotEmpty ||
controller.filterIsActive != null ||
controller.filterRole != null)
ShadcnButton(
text: '필터 초기화',
onPressed: () {
_searchController.clear();
controller.clearFilters();
},
variant: ShadcnButtonVariant.ghost,
icon: const Icon(Icons.clear_all),
),
],
),
],
),
),
// 테이블 데이터
if (pagedUsers.isEmpty)
Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing8),
child: Center(
child: Text(
'등록된 사용자가 없습니다.',
style: ShadcnTheme.bodyMuted,
),
const SizedBox(height: ShadcnTheme.spacing4),
// 헤더 액션 바
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${controller.users.length}명 사용자',
style: ShadcnTheme.bodyMuted,
),
Row(
children: [
ShadcnButton(
text: '새로고침',
onPressed: () => controller.loadUsers(refresh: true),
variant: ShadcnButtonVariant.secondary,
icon: const Icon(Icons.refresh),
),
),
)
else
...pagedUsers.asMap().entries.map((entry) {
final int index = entry.key;
final User user = entry.value;
const SizedBox(width: ShadcnTheme.spacing2),
ShadcnButton(
text: '사용자 추가',
onPressed: _navigateToAdd,
variant: ShadcnButtonVariant.primary,
textColor: Colors.white,
icon: const Icon(Icons.add),
),
],
),
],
),
return Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing4),
const SizedBox(height: ShadcnTheme.spacing4),
// 테이블 컨테이너
Container(
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(color: ShadcnTheme.border),
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
),
child: 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(
'${startIndex + index + 1}',
style: ShadcnTheme.bodySmall,
),
),
// 사용자명
Expanded(
flex: 2,
child: Text(
user.name,
style: ShadcnTheme.bodyMedium,
),
),
// 이메일
Expanded(
flex: 2,
child: Text(
user.email ?? '미등록',
style: ShadcnTheme.bodySmall,
),
),
// 회사명
Expanded(
flex: 2,
child: Text(
_getCompanyName(user.companyId),
style: ShadcnTheme.bodySmall,
),
),
// 지점명
Expanded(
flex: 2,
child: Text(
_controller.getBranchName(
user.companyId,
user.branchId,
),
style: ShadcnTheme.bodySmall,
),
),
// 권한
Expanded(
flex: 1,
child: _buildUserRoleBadge(user.role),
),
// 관리
Expanded(
flex: 1,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(
Icons.edit,
size: 16,
color: ShadcnTheme.primary,
),
onPressed:
user.id != null
? () => _navigateToEdit(user.id!)
: null,
tooltip: '수정',
),
IconButton(
icon: Icon(
Icons.delete,
size: 16,
color: ShadcnTheme.destructive,
),
onPressed:
user.id != null
? () => _showDeleteDialog(user.id!)
: null,
tooltip: '삭제',
),
],
),
),
const SizedBox(width: 50, child: Text('번호', style: TextStyle(fontWeight: FontWeight.bold))),
const Expanded(flex: 2, child: Text('사용자명', style: TextStyle(fontWeight: FontWeight.bold))),
const Expanded(flex: 2, child: Text('이메일', style: TextStyle(fontWeight: FontWeight.bold))),
const Expanded(flex: 2, child: Text('회사명', style: TextStyle(fontWeight: FontWeight.bold))),
const Expanded(flex: 2, child: Text('지점명', style: TextStyle(fontWeight: FontWeight.bold))),
const SizedBox(width: 100, child: Text('권한', style: TextStyle(fontWeight: FontWeight.bold))),
const SizedBox(width: 80, child: Text('상태', style: TextStyle(fontWeight: FontWeight.bold))),
const SizedBox(width: 120, child: Text('관리', style: TextStyle(fontWeight: FontWeight.bold))),
],
),
);
}),
],
),
),
),
// 페이지네이션
if (totalCount > _pageSize) ...[
const SizedBox(height: ShadcnTheme.spacing6),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ShadcnButton(
text: '이전',
onPressed:
_currentPage > 1
? () {
setState(() {
_currentPage--;
});
}
: null,
variant: ShadcnButtonVariant.secondary,
size: ShadcnButtonSize.small,
),
const SizedBox(width: ShadcnTheme.spacing2),
Text(
'$_currentPage / ${(totalCount / _pageSize).ceil()}',
style: ShadcnTheme.bodyMuted,
),
const SizedBox(width: ShadcnTheme.spacing2),
ShadcnButton(
text: '다음',
onPressed:
_currentPage < (totalCount / _pageSize).ceil()
? () {
setState(() {
_currentPage++;
});
}
: null,
variant: ShadcnButtonVariant.secondary,
size: ShadcnButtonSize.small,
),
// 테이블 데이터
if (controller.users.isEmpty)
Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing8),
child: Center(
child: Text(
controller.searchQuery.isNotEmpty ||
controller.filterIsActive != null ||
controller.filterRole != null
? '검색 결과가 없습니다.'
: '등록된 사용자가 없습니다.',
style: ShadcnTheme.bodyMuted,
),
),
)
else
...controller.users.asMap().entries.map((entry) {
final int index = entry.key;
final User user = entry.value;
return Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing4),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: ShadcnTheme.border),
),
color: index % 2 == 0 ? null : ShadcnTheme.muted.withValues(alpha: 0.1),
),
child: Row(
children: [
// 번호
SizedBox(
width: 50,
child: Text(
'${index + 1}',
style: ShadcnTheme.bodySmall,
),
),
// 사용자명
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user.name,
style: ShadcnTheme.bodyMedium,
),
if (user.username != null)
Text(
'@${user.username}',
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.muted,
),
),
],
),
),
// 이메일
Expanded(
flex: 2,
child: Text(
user.email ?? '미등록',
style: ShadcnTheme.bodySmall,
),
),
// 회사명
Expanded(
flex: 2,
child: Text(
_getCompanyName(user.companyId),
style: ShadcnTheme.bodySmall,
),
),
// 지점명
Expanded(
flex: 2,
child: Text(
controller.getBranchName(
user.companyId,
user.branchId,
),
style: ShadcnTheme.bodySmall,
),
),
// 권한
SizedBox(
width: 100,
child: _buildUserRoleBadge(user.role),
),
// 상태
SizedBox(
width: 80,
child: Row(
children: [
Icon(
Icons.circle,
size: 8,
color: _getStatusColor(user.isActive),
),
const SizedBox(width: 4),
Text(
user.isActive ? '활성' : '비활성',
style: ShadcnTheme.bodySmall.copyWith(
color: _getStatusColor(user.isActive),
),
),
],
),
),
// 관리
SizedBox(
width: 120,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(
Icons.power_settings_new,
size: 16,
color: user.isActive ? Colors.orange : Colors.green,
),
onPressed: user.id != null
? () => _showStatusChangeDialog(user)
: null,
tooltip: user.isActive ? '비활성화' : '활성화',
),
IconButton(
icon: Icon(
Icons.edit,
size: 16,
color: ShadcnTheme.primary,
),
onPressed: user.id != null
? () => _navigateToEdit(user.id!)
: null,
tooltip: '수정',
),
IconButton(
icon: Icon(
Icons.delete,
size: 16,
color: ShadcnTheme.destructive,
),
onPressed: user.id != null
? () => _showDeleteDialog(user.id!, user.name)
: null,
tooltip: '삭제',
),
],
),
),
],
),
);
}),
],
),
),
// 무한 스크롤 로딩 인디케이터
if (controller.isLoadingMore)
Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing4),
child: const Center(
child: CircularProgressIndicator(),
),
),
// 더 이상 데이터가 없을 때
if (!controller.hasMoreData && controller.users.isNotEmpty)
Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing4),
child: Center(
child: Text(
'모든 사용자를 불러왔습니다',
style: ShadcnTheme.bodyMuted,
),
),
),
],
),
],
],
);
},
),
);
}