결재 템플릿 단계 적용 구현

- 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 '../../../uom/domain/entities/uom.dart';
@@ -134,223 +138,188 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
? 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.vendorFilter != null ||
_controller.uomFilter != null ||
_controller.statusFilter != ProductStatusFilter.all;
return AppLayout(
title: '장비 모델(제품) 관리',
subtitle: '제품코드, 제조사, 단위 정보를 관리합니다.',
breadcrumbs: const [
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
AppBreadcrumbItem(label: '마스터', path: '/masters/products'),
AppBreadcrumbItem(label: '제품'),
],
actions: [
ShadButton(
leading: const Icon(LucideIcons.plus, size: 16),
onPressed: _controller.isSubmitting
? null
: () => _openProductForm(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(
leading: const Icon(LucideIcons.plus, size: 16),
onPressed: _controller.isSubmitting
? null
: () => _openProductForm(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.vendorFilter),
initialValue: _controller.vendorFilter,
placeholder: const Text('제조사 전체'),
selectedOptionBuilder: (context, value) {
if (value == null) {
return const Text('제조사 전체');
}
final vendor = _controller.vendorOptions
.firstWhere(
(v) => v.id == value,
orElse: () => Vendor(
id: value,
vendorCode: '',
vendorName: '',
),
);
return Text(vendor.vendorName);
},
onChanged: (value) {
_controller.updateVendorFilter(value);
},
options: [
const ShadOption<int?>(
value: null,
child: Text('제조사 전체'),
),
..._controller.vendorOptions.map(
(vendor) => ShadOption<int?>(
value: vendor.id,
child: Text(vendor.vendorName),
),
),
],
),
),
SizedBox(
width: 220,
child: ShadSelect<int?>(
key: ValueKey(_controller.uomFilter),
initialValue: _controller.uomFilter,
placeholder: const Text('단위 전체'),
selectedOptionBuilder: (context, value) {
if (value == null) {
return const Text('단위 전체');
}
final uom = _controller.uomOptions.firstWhere(
(u) => u.id == value,
orElse: () => Uom(id: value, uomName: ''),
);
return Text(uom.uomName);
},
onChanged: (value) {
_controller.updateUomFilter(value);
},
options: [
const ShadOption<int?>(
value: null,
child: Text('단위 전체'),
),
..._controller.uomOptions.map(
(uom) => ShadOption<int?>(
value: uom.id,
child: Text(uom.uomName),
),
),
],
),
),
SizedBox(
width: 200,
child: ShadSelect<ProductStatusFilter>(
key: ValueKey(_controller.statusFilter),
initialValue: _controller.statusFilter,
selectedOptionBuilder: (context, filter) =>
Text(_statusLabel(filter)),
onChanged: (value) {
if (value == null) return;
_controller.updateStatusFilter(value);
},
options: ProductStatusFilter.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.vendorFilter != null ||
_controller.uomFilter != null ||
_controller.statusFilter != ProductStatusFilter.all)
ShadButton.ghost(
onPressed: _controller.isLoading
? null
: () {
_searchController.clear();
_searchFocus.requestFocus();
_controller.updateQuery('');
_controller.updateVendorFilter(null);
_controller.updateUomFilter(null);
_controller.updateStatusFilter(
ProductStatusFilter.all,
);
_controller.fetch(page: 1);
},
child: const Text('초기화'),
),
],
SizedBox(
width: 220,
child: ShadSelect<int?>(
key: ValueKey(_controller.vendorFilter),
initialValue: _controller.vendorFilter,
placeholder: const Text('제조사 전체'),
selectedOptionBuilder: (context, value) {
if (value == null) {
return const Text('제조사 전체');
}
final vendor = _controller.vendorOptions.firstWhere(
(v) => v.id == value,
orElse: () => Vendor(id: value, vendorCode: '', vendorName: ''),
);
return Text(vendor.vendorName);
},
onChanged: (value) => _controller.updateVendorFilter(value),
options: [
const ShadOption<int?>(
value: null,
child: Text('제조사 전체'),
),
..._controller.vendorOptions.map(
(vendor) => ShadOption<int?>(
value: vendor.id,
child: Text(vendor.vendorName),
),
),
],
),
),
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,
SizedBox(
width: 220,
child: ShadSelect<int?>(
key: ValueKey(_controller.uomFilter),
initialValue: _controller.uomFilter,
placeholder: const Text('단위 전체'),
selectedOptionBuilder: (context, value) {
if (value == null) {
return const Text('단위 전체');
}
final uom = _controller.uomOptions.firstWhere(
(u) => u.id == value,
orElse: () => Uom(id: value, uomName: ''),
);
return Text(uom.uomName);
},
onChanged: (value) => _controller.updateUomFilter(value),
options: [
const ShadOption<int?>(
value: null,
child: Text('단위 전체'),
),
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('다음'),
),
],
..._controller.uomOptions.map(
(uom) => ShadOption<int?>(
value: uom.id,
child: Text(uom.uomName),
),
),
],
),
child: _controller.isLoading
? const Padding(
padding: EdgeInsets.all(48),
child: Center(child: CircularProgressIndicator()),
),
SizedBox(
width: 200,
child: ShadSelect<ProductStatusFilter>(
key: ValueKey(_controller.statusFilter),
initialValue: _controller.statusFilter,
selectedOptionBuilder: (context, filter) =>
Text(_statusLabel(filter)),
onChanged: (value) {
if (value == null) return;
_controller.updateStatusFilter(value);
},
options: ProductStatusFilter.values
.map(
(filter) => ShadOption(
value: filter,
child: Text(_statusLabel(filter)),
),
)
: products.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.updateVendorFilter(null);
_controller.updateUomFilter(null);
_controller.updateStatusFilter(
ProductStatusFilter.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()),
)
: products.isEmpty
? Padding(
padding: const EdgeInsets.all(32),
child: Text(
@@ -372,8 +341,6 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
? null
: _restoreProduct,
),
),
],
),
);
},