결재 템플릿 단계 적용 구현

- ApprovalTemplate 엔티티·DTO·원격 리포지토리 추가
- ApprovalController에 템플릿 로딩/적용 상태와 assignSteps 호출 연동
- ApprovalPage 단계 탭에 템플릿 선택 UI 및 적용 확인 다이얼로그 구현
- 템플릿 적용 단위 테스트와 IMPLEMENTATION_TASKS 현황 갱신
This commit is contained in:
JiWoong Sul
2025-09-25 00:21:12 +09:00
parent b6e50464d2
commit c3010965ad
63 changed files with 10179 additions and 1436 deletions

View File

@@ -2,6 +2,10 @@ import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/core/constants/app_sections.dart';
import 'package:superport_v2/widgets/app_layout.dart';
import 'package:superport_v2/widgets/components/filter_bar.dart';
import '../../../../../core/config/environment.dart';
import '../../../../../widgets/spec_page.dart';
import '../../../group/domain/entities/group.dart';
@@ -143,188 +147,162 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
? false
: (result.page * result.pageSize) < result.total;
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
final showReset = _searchController.text.isNotEmpty ||
_controller.groupFilter != null ||
_controller.statusFilter != UserStatusFilter.all;
return AppLayout(
title: '사용자(사원) 관리',
subtitle: '사번 기반 계정과 그룹, 사용 상태를 관리합니다.',
breadcrumbs: const [
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
AppBreadcrumbItem(label: '마스터', path: '/masters/users'),
AppBreadcrumbItem(label: '사용자'),
],
actions: [
ShadButton(
leading: const Icon(LucideIcons.plus, size: 16),
onPressed:
_controller.isSubmitting ? null : () => _openUserForm(context),
child: const Text('신규 등록'),
),
],
toolbar: FilterBar(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('사용자(사원) 관리', style: theme.textTheme.h2),
const SizedBox(height: 6),
Text(
'사번 기반 계정과 그룹, 사용 상태를 관리합니다.',
style: theme.textTheme.muted,
),
],
),
),
const SizedBox(width: 16),
ShadButton(
onPressed: _controller.isSubmitting
? null
: () => _openUserForm(context),
child: const Text('신규 등록'),
),
],
SizedBox(
width: 260,
child: ShadInput(
controller: _searchController,
focusNode: _searchFocus,
placeholder: const Text('사번, 성명, 이메일 검색'),
leading: const Icon(LucideIcons.search, size: 16),
onSubmitted: (_) => _applyFilters(),
),
),
const SizedBox(height: 24),
ShadCard(
title: Text('검색 및 필터', style: theme.textTheme.h3),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 16,
runSpacing: 16,
children: [
SizedBox(
width: 260,
child: ShadInput(
controller: _searchController,
focusNode: _searchFocus,
placeholder: const Text('사번, 성명, 이메일 검색'),
leading: const Icon(LucideIcons.search, size: 16),
onSubmitted: (_) => _applyFilters(),
),
),
SizedBox(
width: 220,
child: ShadSelect<int?>(
key: ValueKey(_controller.groupFilter),
initialValue: _controller.groupFilter,
placeholder: Text(
_groupsLoaded ? '그룹 전체' : '그룹 로딩중...',
),
selectedOptionBuilder: (context, value) {
if (value == null) {
return Text(
_groupsLoaded ? '그룹 전체' : '그룹 로딩중...',
);
}
final group = _controller.groups.firstWhere(
(g) => g.id == value,
orElse: () => Group(id: value, groupName: ''),
);
return Text(group.groupName);
},
onChanged: _controller.isLoadingGroups
? null
: (value) {
_controller.updateGroupFilter(value);
},
options: [
const ShadOption<int?>(
value: null,
child: Text('그룹 전체'),
),
..._controller.groups.map(
(group) => ShadOption<int?>(
value: group.id,
child: Text(group.groupName),
),
),
],
),
),
SizedBox(
width: 200,
child: ShadSelect<UserStatusFilter>(
key: ValueKey(_controller.statusFilter),
initialValue: _controller.statusFilter,
selectedOptionBuilder: (context, filter) =>
Text(_statusLabel(filter)),
onChanged: (value) {
if (value == null) return;
_controller.updateStatusFilter(value);
},
options: UserStatusFilter.values
.map(
(filter) => ShadOption(
value: filter,
child: Text(_statusLabel(filter)),
),
)
.toList(),
),
),
ShadButton.outline(
onPressed: _controller.isLoading
? null
: _applyFilters,
child: const Text('검색 적용'),
),
if (_searchController.text.isNotEmpty ||
_controller.groupFilter != null ||
_controller.statusFilter != UserStatusFilter.all)
ShadButton.ghost(
onPressed: _controller.isLoading
? null
: () {
_searchController.clear();
_searchFocus.requestFocus();
_controller.updateQuery('');
_controller.updateGroupFilter(null);
_controller.updateStatusFilter(
UserStatusFilter.all,
);
_controller.fetch(page: 1);
},
child: const Text('초기화'),
),
],
SizedBox(
width: 220,
child: ShadSelect<int?>(
key: ValueKey(_controller.groupFilter),
initialValue: _controller.groupFilter,
placeholder: Text(
_groupsLoaded ? '그룹 전체' : '그룹 로딩중...',
),
selectedOptionBuilder: (context, value) {
if (value == null) {
return Text(
_groupsLoaded ? '그룹 전체' : '그룹 로딩중...',
);
}
final group = _controller.groups.firstWhere(
(g) => g.id == value,
orElse: () => Group(id: value, groupName: ''),
);
return Text(group.groupName);
},
onChanged: _controller.isLoadingGroups
? null
: (value) {
_controller.updateGroupFilter(value);
},
options: [
const ShadOption<int?>(
value: null,
child: Text('그룹 전체'),
),
..._controller.groups.map(
(group) => ShadOption<int?>(
value: group.id,
child: Text(group.groupName),
),
),
],
),
),
const SizedBox(height: 24),
ShadCard(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('사용자 목록', style: theme.textTheme.h3),
Text('$totalCount건', style: theme.textTheme.muted),
],
),
footer: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'페이지 $currentPage / $totalPages',
style: theme.textTheme.small,
),
Row(
children: [
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: _controller.isLoading || currentPage <= 1
? null
: () => _controller.fetch(page: currentPage - 1),
child: const Text('이전'),
SizedBox(
width: 200,
child: ShadSelect<UserStatusFilter>(
key: ValueKey(_controller.statusFilter),
initialValue: _controller.statusFilter,
selectedOptionBuilder: (context, filter) =>
Text(_statusLabel(filter)),
onChanged: (value) {
if (value == null) return;
_controller.updateStatusFilter(value);
},
options: UserStatusFilter.values
.map(
(filter) => ShadOption(
value: filter,
child: Text(_statusLabel(filter)),
),
const SizedBox(width: 8),
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: _controller.isLoading || !hasNext
? null
: () => _controller.fetch(page: currentPage + 1),
child: const Text('다음'),
),
],
),
],
),
child: _controller.isLoading
? const Padding(
padding: EdgeInsets.all(48),
child: Center(child: CircularProgressIndicator()),
)
: users.isEmpty
.toList(),
),
),
ShadButton.outline(
onPressed: _controller.isLoading ? null : _applyFilters,
child: const Text('검색 적용'),
),
if (showReset)
ShadButton.ghost(
onPressed: _controller.isLoading
? null
: () {
_searchController.clear();
_searchFocus.requestFocus();
_controller.updateQuery('');
_controller.updateGroupFilter(null);
_controller.updateStatusFilter(
UserStatusFilter.all,
);
_controller.fetch(page: 1);
},
child: const Text('초기화'),
),
],
),
child: ShadCard(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('사용자 목록', style: theme.textTheme.h3),
Text('$totalCount건', style: theme.textTheme.muted),
],
),
footer: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'페이지 $currentPage / $totalPages',
style: theme.textTheme.small,
),
Row(
children: [
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: _controller.isLoading || currentPage <= 1
? null
: () => _controller.fetch(page: currentPage - 1),
child: const Text('이전'),
),
const SizedBox(width: 8),
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: _controller.isLoading || !hasNext
? null
: () => _controller.fetch(page: currentPage + 1),
child: const Text('다음'),
),
],
),
],
),
child: _controller.isLoading
? const Padding(
padding: EdgeInsets.all(48),
child: Center(child: CircularProgressIndicator()),
)
: users.isEmpty
? Padding(
padding: const EdgeInsets.all(32),
child: Text(
@@ -344,8 +322,6 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
? null
: _restoreUser,
),
),
],
),
);
},