- Replace dart:js with package:js in health_check_service_web.dart\n- Implement showHealthCheckNotification in web/index.html\n- Pin js dependency to ^0.6.7 for flutter_secure_storage_web compatibility auth: harden AuthInterceptor + tests - Allow overrideAuthRepository injection for testing\n- Normalize imports to package: paths\n- Add unit test covering token attach, 401→refresh→retry, and failure path\n- Add integration test skeleton gated by env vars ui/data: map User.companyName to list column - Add companyName to domain User\n- Map UserDto.company?.name\n- Render companyName in user_list cleanup: remove legacy equipment table + unused code; minor warnings - Remove _buildFlexibleTable and unused helpers\n- Remove unused zipcode details and cache retry constant\n- Fix null-aware and non-null assertions\n- Address child-last warnings in administrator dialog docs: update AGENTS.md session context
435 lines
14 KiB
Dart
435 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/layouts/base_list_screen.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/core/constants/app_constants.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: AppConstants.userPageSize);
|
|
});
|
|
|
|
_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;
|
|
Color backgroundColor;
|
|
Color foregroundColor;
|
|
|
|
switch (role) {
|
|
case UserRole.admin:
|
|
backgroundColor = ShadcnTheme.alertCritical7.withValues(alpha: 0.1);
|
|
foregroundColor = ShadcnTheme.alertCritical7;
|
|
break;
|
|
case UserRole.manager:
|
|
backgroundColor = ShadcnTheme.companyHeadquarters.withValues(alpha: 0.1);
|
|
foregroundColor = ShadcnTheme.companyHeadquarters;
|
|
break;
|
|
case UserRole.staff:
|
|
backgroundColor = ShadcnTheme.equipmentDisposal.withValues(alpha: 0.1);
|
|
foregroundColor = ShadcnTheme.equipmentDisposal;
|
|
break;
|
|
}
|
|
|
|
return ShadBadge(
|
|
backgroundColor: backgroundColor,
|
|
foregroundColor: foregroundColor,
|
|
child: Text(roleName),
|
|
);
|
|
}
|
|
|
|
/// 사용자 추가 폼으로 이동
|
|
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.companyHeadquarters,
|
|
),
|
|
const SizedBox(width: ShadcnTheme.spacing4),
|
|
_buildStatCard(
|
|
'활성 사용자',
|
|
_controller.users.where((u) => u.isActive).length.toString(),
|
|
Icons.check_circle,
|
|
ShadcnTheme.equipmentIn,
|
|
),
|
|
const SizedBox(width: ShadcnTheme.spacing4),
|
|
_buildStatCard(
|
|
'비활성 사용자',
|
|
_controller.users.where((u) => !u.isActive).length.toString(),
|
|
Icons.person_off,
|
|
ShadcnTheme.equipmentDisposal,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
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() {
|
|
final users = _controller.users;
|
|
|
|
if (users.isEmpty) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.people_outlined,
|
|
size: 64,
|
|
color: ShadcnTheme.mutedForeground,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'등록된 사용자가 없습니다',
|
|
style: ShadcnTheme.bodyMedium.copyWith(
|
|
color: ShadcnTheme.mutedForeground,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return Container(
|
|
width: double.infinity,
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: ShadcnTheme.border),
|
|
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12.0),
|
|
child: ShadTable.list(
|
|
header: const [
|
|
ShadTableCell.header(child: Text('번호')),
|
|
ShadTableCell.header(child: Text('이름')),
|
|
ShadTableCell.header(child: Text('이메일')),
|
|
ShadTableCell.header(child: Text('회사')),
|
|
ShadTableCell.header(child: Text('권한')),
|
|
ShadTableCell.header(child: Text('상태')),
|
|
ShadTableCell.header(child: Text('작업')),
|
|
],
|
|
children: [
|
|
for (int index = 0; index < users.length; index++)
|
|
[
|
|
// 번호
|
|
ShadTableCell(child: Text(((_controller.currentPage - 1) * _controller.pageSize + index + 1).toString(), style: ShadcnTheme.bodySmall)),
|
|
// 이름
|
|
ShadTableCell(child: Text(users[index].name, overflow: TextOverflow.ellipsis)),
|
|
// 이메일
|
|
ShadTableCell(child: Text(users[index].email ?? '-', overflow: TextOverflow.ellipsis, style: ShadcnTheme.bodySmall)),
|
|
// 회사
|
|
ShadTableCell(child: Text(users[index].companyName ?? '-', overflow: TextOverflow.ellipsis)),
|
|
// 권한
|
|
ShadTableCell(child: _buildUserRoleBadge(users[index].role)),
|
|
// 상태
|
|
ShadTableCell(child: _buildStatusChip(users[index].isActive)),
|
|
// 작업
|
|
ShadTableCell(
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ShadButton.ghost(
|
|
onPressed: users[index].id != null ? () => _navigateToEdit(users[index].id!) : null,
|
|
child: const Icon(Icons.edit, size: 16),
|
|
),
|
|
const SizedBox(width: ShadcnTheme.spacing1),
|
|
ShadButton.ghost(
|
|
onPressed: () => _showStatusChangeDialog(users[index]),
|
|
child: Icon(users[index].isActive ? Icons.person_off : Icons.person, size: 16),
|
|
),
|
|
const SizedBox(width: ShadcnTheme.spacing1),
|
|
ShadButton.ghost(
|
|
onPressed: users[index].id != null ? () => _showDeleteDialog(users[index].id!, users[index].name) : null,
|
|
child: const Icon(Icons.delete, size: 16),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// (Deprecated) 기존 커스텀 테이블 빌더 유틸들은 ShadTable 전환으로 제거되었습니다.
|
|
|
|
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(
|
|
backgroundColor: ShadcnTheme.equipmentIn.withValues(alpha: 0.1),
|
|
foregroundColor: ShadcnTheme.equipmentIn,
|
|
child: const Text('활성'),
|
|
);
|
|
} else {
|
|
return ShadBadge(
|
|
backgroundColor: ShadcnTheme.equipmentDisposal.withValues(alpha: 0.1),
|
|
foregroundColor: ShadcnTheme.equipmentDisposal,
|
|
child: const Text('비활성'),
|
|
);
|
|
}
|
|
}
|
|
|
|
// StandardDataRow 임시 정의
|
|
}
|