- CLAUDE.md 대폭 개선: 개발 가이드라인 및 프로젝트 상태 문서화 - 백엔드 API 통합: 모든 엔티티 간 Foreign Key 관계 완벽 구현 - UI 일관성 강화: shadcn_ui 컴포넌트 표준화 적용 - 데이터 모델 개선: DTO 및 모델 클래스 백엔드 스키마와 100% 일치 - 사용자 관리: 회사 연결, 중복 검사, 입력 검증 기능 추가 - 창고 관리: 우편번호 연결, 중복 검사 기능 강화 - 회사 관리: 우편번호 연결, 중복 검사 로직 구현 - 장비 관리: 불필요한 카테고리 필드 제거, 벤더-모델 관계 정리 - 우편번호 시스템: 검색 다이얼로그 Provider 버그 수정 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
486 lines
14 KiB
Dart
486 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:shadcn_ui/shadcn_ui.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/common/layouts/base_list_screen.dart';
|
|
import 'package:superport/screens/common/widgets/standard_data_table.dart';
|
|
import 'package:superport/screens/common/widgets/standard_action_bar.dart';
|
|
import 'package:superport/screens/common/widgets/pagination.dart';
|
|
import 'package:superport/screens/user/controllers/user_list_controller.dart';
|
|
import 'package:superport/utils/constants.dart';
|
|
|
|
/// shadcn/ui 스타일로 재설계된 사용자 관리 화면
|
|
class UserList extends StatefulWidget {
|
|
const UserList({super.key});
|
|
|
|
@override
|
|
State<UserList> createState() => _UserListState();
|
|
}
|
|
|
|
class _UserListState extends State<UserList> {
|
|
late UserListController _controller;
|
|
final TextEditingController _searchController = TextEditingController();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
_controller = UserListController();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_controller.initialize(pageSize: 10);
|
|
});
|
|
|
|
_searchController.addListener(() {
|
|
_onSearchChanged(_searchController.text);
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
_searchController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Timer? _debounce;
|
|
void _onSearchChanged(String query) {
|
|
if (_debounce?.isActive ?? false) _debounce?.cancel();
|
|
_debounce = Timer(const Duration(milliseconds: 500), () {
|
|
_controller.setSearchQuery(query);
|
|
});
|
|
}
|
|
|
|
/// 사용자 권한 표시 배지
|
|
Widget _buildUserRoleBadge(UserRole role) {
|
|
final roleName = role.displayName;
|
|
ShadcnBadgeVariant variant;
|
|
|
|
switch (role) {
|
|
case UserRole.admin:
|
|
variant = ShadcnBadgeVariant.destructive;
|
|
break;
|
|
case UserRole.manager:
|
|
variant = ShadcnBadgeVariant.primary;
|
|
break;
|
|
case UserRole.staff:
|
|
variant = ShadcnBadgeVariant.secondary;
|
|
break;
|
|
}
|
|
|
|
return ShadcnBadge(
|
|
text: roleName,
|
|
variant: variant,
|
|
size: ShadcnBadgeSize.small,
|
|
);
|
|
}
|
|
|
|
/// 사용자 추가 폼으로 이동
|
|
void _navigateToAdd() async {
|
|
final result = await Navigator.pushNamed(context, Routes.userAdd);
|
|
if (result == true && mounted) {
|
|
_controller.loadUsers(refresh: true);
|
|
}
|
|
}
|
|
|
|
/// 사용자 수정 폼으로 이동
|
|
void _navigateToEdit(int userId) async {
|
|
final result = await Navigator.pushNamed(
|
|
context,
|
|
Routes.userEdit,
|
|
arguments: userId,
|
|
);
|
|
if (result == true && mounted) {
|
|
_controller.loadUsers(refresh: true);
|
|
}
|
|
}
|
|
|
|
/// 사용자 삭제 다이얼로그
|
|
void _showDeleteDialog(int userId, String userName) {
|
|
showShadDialog(
|
|
context: context,
|
|
builder: (context) => ShadDialog(
|
|
title: const Text('사용자 삭제'),
|
|
description: Text('"$userName" 사용자를 정말로 삭제하시겠습니까?'),
|
|
actions: [
|
|
ShadButton.outline(
|
|
child: const Text('취소'),
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
ShadButton.destructive(
|
|
child: const Text('삭제'),
|
|
onPressed: () async {
|
|
Navigator.of(context).pop();
|
|
|
|
await _controller.deleteUser(userId);
|
|
ShadToaster.of(context).show(
|
|
ShadToast(
|
|
title: const Text('삭제 완료'),
|
|
description: const Text('사용자가 삭제되었습니다'),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 상태 변경 확인 다이얼로그
|
|
void _showStatusChangeDialog(User user) {
|
|
final newStatus = !user.isActive;
|
|
final statusText = newStatus ? '활성화' : '비활성화';
|
|
|
|
showShadDialog(
|
|
context: context,
|
|
builder: (context) => ShadDialog(
|
|
title: const Text('사용자 상태 변경'),
|
|
description: Text('"${user.name}" 사용자를 $statusText 하시겠습니까?'),
|
|
actions: [
|
|
ShadButton.outline(
|
|
child: const Text('취소'),
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
ShadButton(
|
|
child: Text(statusText),
|
|
onPressed: () async {
|
|
Navigator.of(context).pop();
|
|
await _controller.changeUserStatus(user, !user.isActive);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: ShadcnTheme.background,
|
|
appBar: AppBar(
|
|
title: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'사용자 관리',
|
|
style: ShadcnTheme.headingH4,
|
|
),
|
|
Text(
|
|
'시스템 사용자를 관리합니다',
|
|
style: ShadcnTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
backgroundColor: ShadcnTheme.background,
|
|
elevation: 0,
|
|
),
|
|
body: ListenableBuilder(
|
|
listenable: _controller,
|
|
builder: (context, child) {
|
|
return BaseListScreen(
|
|
headerSection: _buildStatisticsCards(),
|
|
searchBar: _buildSearchBar(),
|
|
actionBar: _buildActionBar(),
|
|
dataTable: _buildDataTable(),
|
|
pagination: _buildPagination(),
|
|
isLoading: _controller.isLoading && _controller.users.isEmpty,
|
|
error: _controller.error,
|
|
onRefresh: () => _controller.loadUsers(refresh: true),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStatisticsCards() {
|
|
if (_controller.isLoading) return const SizedBox();
|
|
|
|
return Row(
|
|
children: [
|
|
_buildStatCard(
|
|
'전체 사용자',
|
|
_controller.total.toString(),
|
|
Icons.people,
|
|
ShadcnTheme.primary,
|
|
),
|
|
const SizedBox(width: ShadcnTheme.spacing4),
|
|
_buildStatCard(
|
|
'활성 사용자',
|
|
_controller.users.where((u) => u.isActive).length.toString(),
|
|
Icons.check_circle,
|
|
ShadcnTheme.success,
|
|
),
|
|
const SizedBox(width: ShadcnTheme.spacing4),
|
|
_buildStatCard(
|
|
'비활성 사용자',
|
|
_controller.users.where((u) => !u.isActive).length.toString(),
|
|
Icons.person_off,
|
|
ShadcnTheme.mutedForeground,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildSearchBar() {
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: ShadInputFormField(
|
|
controller: _searchController,
|
|
placeholder: const Text('이름, 이메일로 검색...'),
|
|
),
|
|
),
|
|
const SizedBox(width: ShadcnTheme.spacing4),
|
|
ShadButton.outline(
|
|
onPressed: () => _controller.clearFilters(),
|
|
child: const Text('초기화'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildActionBar() {
|
|
return StandardActionBar(
|
|
totalCount: _controller.totalCount,
|
|
leftActions: const [
|
|
Text('사용자 목록', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
|
],
|
|
rightActions: [
|
|
ShadButton(
|
|
onPressed: _navigateToAdd,
|
|
child: const Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.person_add, size: 16),
|
|
SizedBox(width: ShadcnTheme.spacing1),
|
|
Text('사용자 추가'),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildDataTable() {
|
|
if (_controller.users.isEmpty && !_controller.isLoading) {
|
|
return StandardDataTable(
|
|
columns: _getColumns(),
|
|
rows: const [],
|
|
emptyMessage: '등록된 사용자가 없습니다',
|
|
emptyIcon: Icons.people_outlined,
|
|
);
|
|
}
|
|
|
|
return StandardDataTable(
|
|
columns: _getColumns(),
|
|
rows: _buildRows(),
|
|
fixedHeader: true,
|
|
maxHeight: 600,
|
|
);
|
|
}
|
|
|
|
List<StandardDataColumn> _getColumns() {
|
|
return [
|
|
StandardDataColumn(label: 'No.', width: 60),
|
|
StandardDataColumn(label: '이름', flex: 1),
|
|
StandardDataColumn(label: '이메일', flex: 2),
|
|
StandardDataColumn(label: '회사', flex: 1),
|
|
StandardDataColumn(label: '권한', width: 80),
|
|
StandardDataColumn(label: '상태', width: 80),
|
|
StandardDataColumn(label: '작업', width: 120),
|
|
];
|
|
}
|
|
|
|
List<Widget> _buildRows() {
|
|
return _controller.users.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final user = entry.value;
|
|
final rowNumber = (_controller.currentPage - 1) * _controller.pageSize + index + 1;
|
|
|
|
return StandardDataRow(
|
|
index: index,
|
|
cells: [
|
|
Text(
|
|
rowNumber.toString(),
|
|
style: ShadcnTheme.bodyMedium,
|
|
),
|
|
Text(
|
|
user.name,
|
|
style: ShadcnTheme.bodyMedium.copyWith(
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
Text(
|
|
user.email ?? '',
|
|
style: ShadcnTheme.bodyMedium,
|
|
),
|
|
Text(
|
|
'-', // Company name not available in current model
|
|
style: ShadcnTheme.bodySmall,
|
|
),
|
|
_buildUserRoleBadge(user.role),
|
|
_buildStatusChip(user.isActive),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ShadButton.ghost(
|
|
onPressed: user.id != null ? () => _navigateToEdit(user.id!) : null,
|
|
child: const Icon(Icons.edit, size: 16),
|
|
),
|
|
const SizedBox(width: ShadcnTheme.spacing1),
|
|
ShadButton.ghost(
|
|
onPressed: () => _showStatusChangeDialog(user),
|
|
child: Icon(
|
|
user.isActive ? Icons.person_off : Icons.person,
|
|
size: 16,
|
|
),
|
|
),
|
|
const SizedBox(width: ShadcnTheme.spacing1),
|
|
ShadButton.ghost(
|
|
onPressed: user.id != null ? () => _showDeleteDialog(user.id!, user.name) : null,
|
|
child: const Icon(Icons.delete, size: 16),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}).toList();
|
|
}
|
|
|
|
Widget _buildPagination() {
|
|
if (_controller.totalPages <= 1) return const SizedBox();
|
|
|
|
return Pagination(
|
|
currentPage: _controller.currentPage,
|
|
totalCount: _controller.total,
|
|
pageSize: _controller.pageSize,
|
|
onPageChanged: (page) => _controller.goToPage(page),
|
|
);
|
|
}
|
|
|
|
Widget _buildStatCard(
|
|
String title,
|
|
String value,
|
|
IconData icon,
|
|
Color color,
|
|
) {
|
|
return Expanded(
|
|
child: ShadCard(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(ShadcnTheme.spacing4),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(ShadcnTheme.spacing3),
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg),
|
|
),
|
|
child: Icon(
|
|
icon,
|
|
color: color,
|
|
size: 20,
|
|
),
|
|
),
|
|
const SizedBox(width: ShadcnTheme.spacing3),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: ShadcnTheme.bodySmall,
|
|
),
|
|
Text(
|
|
value,
|
|
style: ShadcnTheme.headingH6.copyWith(
|
|
color: color,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStatusChip(bool isActive) {
|
|
if (isActive) {
|
|
return ShadBadge.secondary(
|
|
child: const Text('활성'),
|
|
);
|
|
} else {
|
|
return ShadBadge.destructive(
|
|
child: const Text('비활성'),
|
|
);
|
|
}
|
|
}
|
|
|
|
// StandardDataRow 임시 정의
|
|
}
|
|
|
|
/// 표준 데이터 행 위젯 (임시)
|
|
class StandardDataRow extends StatelessWidget {
|
|
final int index;
|
|
final List<Widget> cells;
|
|
final VoidCallback? onTap;
|
|
final bool selected;
|
|
|
|
const StandardDataRow({
|
|
super.key,
|
|
required this.index,
|
|
required this.cells,
|
|
this.onTap,
|
|
this.selected = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return InkWell(
|
|
onTap: onTap,
|
|
child: Container(
|
|
height: 56,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: ShadcnTheme.spacing4,
|
|
vertical: ShadcnTheme.spacing3,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: selected
|
|
? ShadcnTheme.primaryLight.withValues(alpha: 0.1)
|
|
: (index.isEven
|
|
? ShadcnTheme.muted.withValues(alpha: 0.3)
|
|
: null),
|
|
border: const Border(
|
|
bottom: BorderSide(color: ShadcnTheme.border, width: 1),
|
|
),
|
|
),
|
|
child: Row(
|
|
children: _buildCellWidgets(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
List<Widget> _buildCellWidgets() {
|
|
return cells.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final cell = entry.value;
|
|
|
|
// 마지막 셀이 아니면 오른쪽에 간격 추가
|
|
if (index < cells.length - 1) {
|
|
return Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(right: ShadcnTheme.spacing2),
|
|
child: cell,
|
|
),
|
|
);
|
|
} else {
|
|
return cell;
|
|
}
|
|
}).toList();
|
|
}
|
|
} |