Files
superport/lib/screens/user/user_list.dart
JiWoong Sul 655d473413
Some checks failed
Flutter Test & Quality Check / Test on macos-latest (push) Has been cancelled
Flutter Test & Quality Check / Test on ubuntu-latest (push) Has been cancelled
Flutter Test & Quality Check / Build APK (push) Has been cancelled
web: migrate health notifications to js_interop; add browser hook
- 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
2025-09-08 17:39:00 +09:00

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 임시 정의
}