결재 비활성 안내 개선 및 테이블 기능 보강
This commit is contained in:
@@ -19,6 +19,7 @@ class ApprovalHistoryController extends ChangeNotifier {
|
|||||||
DateTime? _from;
|
DateTime? _from;
|
||||||
DateTime? _to;
|
DateTime? _to;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
int _pageSize = 20;
|
||||||
|
|
||||||
PaginatedResult<ApprovalHistoryRecord>? get result => _result;
|
PaginatedResult<ApprovalHistoryRecord>? get result => _result;
|
||||||
bool get isLoading => _isLoading;
|
bool get isLoading => _isLoading;
|
||||||
@@ -27,6 +28,7 @@ class ApprovalHistoryController extends ChangeNotifier {
|
|||||||
DateTime? get from => _from;
|
DateTime? get from => _from;
|
||||||
DateTime? get to => _to;
|
DateTime? get to => _to;
|
||||||
String? get errorMessage => _errorMessage;
|
String? get errorMessage => _errorMessage;
|
||||||
|
int get pageSize => _result?.pageSize ?? _pageSize;
|
||||||
|
|
||||||
Future<void> fetch({int page = 1}) async {
|
Future<void> fetch({int page = 1}) async {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
@@ -42,13 +44,14 @@ class ApprovalHistoryController extends ChangeNotifier {
|
|||||||
|
|
||||||
final response = await _repository.list(
|
final response = await _repository.list(
|
||||||
page: page,
|
page: page,
|
||||||
pageSize: _result?.pageSize ?? 20,
|
pageSize: _pageSize,
|
||||||
query: _query.trim().isEmpty ? null : _query.trim(),
|
query: _query.trim().isEmpty ? null : _query.trim(),
|
||||||
action: action,
|
action: action,
|
||||||
from: _from,
|
from: _from,
|
||||||
to: _to,
|
to: _to,
|
||||||
);
|
);
|
||||||
_result = response;
|
_result = response;
|
||||||
|
_pageSize = response.pageSize;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_errorMessage = e.toString();
|
_errorMessage = e.toString();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -86,6 +89,14 @@ class ApprovalHistoryController extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void updatePageSize(int value) {
|
||||||
|
if (value <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_pageSize = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
bool get hasActiveFilters =>
|
bool get hasActiveFilters =>
|
||||||
_query.trim().isNotEmpty ||
|
_query.trim().isNotEmpty ||
|
||||||
_actionFilter != ApprovalHistoryActionFilter.all ||
|
_actionFilter != ApprovalHistoryActionFilter.all ||
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import '../../../../../widgets/app_layout.dart';
|
|||||||
import '../../../../../widgets/components/filter_bar.dart';
|
import '../../../../../widgets/components/filter_bar.dart';
|
||||||
import '../../../../../widgets/components/superport_date_picker.dart';
|
import '../../../../../widgets/components/superport_date_picker.dart';
|
||||||
import '../../../../../widgets/components/superport_table.dart';
|
import '../../../../../widgets/components/superport_table.dart';
|
||||||
import '../../../../../widgets/spec_page.dart';
|
import '../../../../../widgets/components/feature_disabled_placeholder.dart';
|
||||||
import '../../domain/entities/approval_history_record.dart';
|
import '../../domain/entities/approval_history_record.dart';
|
||||||
import '../../domain/repositories/approval_history_repository.dart';
|
import '../../domain/repositories/approval_history_repository.dart';
|
||||||
import '../controllers/approval_history_controller.dart';
|
import '../controllers/approval_history_controller.dart';
|
||||||
@@ -22,41 +22,28 @@ class ApprovalHistoryPage extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final enabled = Environment.flag('FEATURE_APPROVALS_ENABLED');
|
final enabled = Environment.flag('FEATURE_APPROVALS_ENABLED');
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
return const SpecPage(
|
return AppLayout(
|
||||||
title: '결재 이력 조회',
|
title: '결재 이력 조회',
|
||||||
summary: '결재 단계별 변경 이력을 조회합니다.',
|
subtitle: '결재 단계별 변경 내역을 기간·행위·결재번호 기준으로 확인합니다.',
|
||||||
sections: [
|
breadcrumbs: const [
|
||||||
SpecSection(
|
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
|
||||||
title: '조회 테이블',
|
AppBreadcrumbItem(label: '결재', path: '/approvals/history'),
|
||||||
description: '수정 없이 이력 리스트만 제공.',
|
AppBreadcrumbItem(label: '결재 이력'),
|
||||||
table: SpecTable(
|
],
|
||||||
columns: [
|
actions: [
|
||||||
'번호',
|
Tooltip(
|
||||||
'결재ID',
|
message: '다운로드는 API 연동 이후 제공됩니다.',
|
||||||
'단계순서',
|
child: ShadButton(
|
||||||
'승인자',
|
onPressed: null,
|
||||||
'행위',
|
leading: const Icon(lucide.LucideIcons.download, size: 16),
|
||||||
'변경전상태',
|
child: const Text('엑셀 다운로드'),
|
||||||
'변경후상태',
|
|
||||||
'작업일시',
|
|
||||||
'비고',
|
|
||||||
],
|
|
||||||
rows: [
|
|
||||||
[
|
|
||||||
'1',
|
|
||||||
'APP-20240301-001',
|
|
||||||
'1',
|
|
||||||
'최관리',
|
|
||||||
'승인',
|
|
||||||
'승인대기',
|
|
||||||
'승인완료',
|
|
||||||
'2024-03-01 10:30',
|
|
||||||
'-',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
child: const FeatureDisabledPlaceholder(
|
||||||
|
title: '결재 이력 기능 준비 중',
|
||||||
|
description: '결재 이력 API가 공개되면 검색과 엑셀 다운로드 기능이 활성화됩니다.',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +67,10 @@ class _ApprovalHistoryEnabledPageState
|
|||||||
final DateFormat _dateTimeFormat = DateFormat('yyyy-MM-dd HH:mm');
|
final DateFormat _dateTimeFormat = DateFormat('yyyy-MM-dd HH:mm');
|
||||||
DateTimeRange? _dateRange;
|
DateTimeRange? _dateRange;
|
||||||
String? _lastError;
|
String? _lastError;
|
||||||
|
static const _pageSizeOptions = [10, 20, 50];
|
||||||
|
int? _sortColumnIndex;
|
||||||
|
bool _sortAscending = true;
|
||||||
|
static const _sortableColumns = {0, 1, 2, 3, 4, 5, 6, 7};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -125,9 +116,7 @@ class _ApprovalHistoryEnabledPageState
|
|||||||
final totalPages = result == null || result.pageSize == 0
|
final totalPages = result == null || result.pageSize == 0
|
||||||
? 1
|
? 1
|
||||||
: (result.total / result.pageSize).ceil().clamp(1, 9999);
|
: (result.total / result.pageSize).ceil().clamp(1, 9999);
|
||||||
final hasNext = result == null
|
final sortedHistories = _applySorting(histories);
|
||||||
? false
|
|
||||||
: (result.page * result.pageSize) < result.total;
|
|
||||||
|
|
||||||
return AppLayout(
|
return AppLayout(
|
||||||
title: '결재 이력 조회',
|
title: '결재 이력 조회',
|
||||||
@@ -231,34 +220,6 @@ class _ApprovalHistoryEnabledPageState
|
|||||||
Text('$totalCount건', style: theme.textTheme.muted),
|
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
|
child: _controller.isLoading
|
||||||
? const Padding(
|
? const Padding(
|
||||||
padding: EdgeInsets.all(48),
|
padding: EdgeInsets.all(48),
|
||||||
@@ -273,9 +234,30 @@ class _ApprovalHistoryEnabledPageState
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: _ApprovalHistoryTable(
|
: _ApprovalHistoryTable(
|
||||||
histories: histories,
|
histories: sortedHistories,
|
||||||
dateFormat: _dateTimeFormat,
|
dateFormat: _dateTimeFormat,
|
||||||
query: _controller.query,
|
query: _controller.query,
|
||||||
|
pagination: SuperportTablePagination(
|
||||||
|
currentPage: currentPage,
|
||||||
|
totalPages: totalPages,
|
||||||
|
totalItems: totalCount,
|
||||||
|
pageSize: _controller.pageSize,
|
||||||
|
pageSizeOptions: _pageSizeOptions,
|
||||||
|
),
|
||||||
|
onPageChange: (page) => _controller.fetch(page: page),
|
||||||
|
onPageSizeChange: (size) {
|
||||||
|
_controller.updatePageSize(size);
|
||||||
|
_controller.fetch(page: 1);
|
||||||
|
},
|
||||||
|
isLoading: _controller.isLoading,
|
||||||
|
sortableColumns: _sortableColumns,
|
||||||
|
sortState: _sortColumnIndex == null
|
||||||
|
? null
|
||||||
|
: SuperportTableSortState(
|
||||||
|
columnIndex: _sortColumnIndex!,
|
||||||
|
ascending: _sortAscending,
|
||||||
|
),
|
||||||
|
onSortChanged: _handleSortChange,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -305,6 +287,58 @@ class _ApprovalHistoryEnabledPageState
|
|||||||
_controller.fetch(page: 1);
|
_controller.fetch(page: 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleSortChange(int columnIndex, bool ascending) {
|
||||||
|
setState(() {
|
||||||
|
_sortColumnIndex = columnIndex;
|
||||||
|
_sortAscending = ascending;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ApprovalHistoryRecord> _applySorting(List<ApprovalHistoryRecord> items) {
|
||||||
|
final columnIndex = _sortColumnIndex;
|
||||||
|
if (columnIndex == null) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
final sorted = List<ApprovalHistoryRecord>.from(items);
|
||||||
|
sorted.sort((a, b) {
|
||||||
|
int compare;
|
||||||
|
switch (columnIndex) {
|
||||||
|
case 0:
|
||||||
|
compare = a.id.compareTo(b.id);
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
compare = a.approvalNo.compareTo(b.approvalNo);
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
final left = a.stepOrder ?? 0;
|
||||||
|
final right = b.stepOrder ?? 0;
|
||||||
|
compare = left.compareTo(right);
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
compare = a.approver.name.compareTo(b.approver.name);
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
compare = a.action.name.compareTo(b.action.name);
|
||||||
|
break;
|
||||||
|
case 5:
|
||||||
|
compare = (a.fromStatus?.name ?? '').compareTo(
|
||||||
|
b.fromStatus?.name ?? '',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 6:
|
||||||
|
compare = a.toStatus.name.compareTo(b.toStatus.name);
|
||||||
|
break;
|
||||||
|
case 7:
|
||||||
|
compare = a.actionAt.compareTo(b.actionAt);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
compare = 0;
|
||||||
|
}
|
||||||
|
return _sortAscending ? compare : -compare;
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
String _actionLabel(ApprovalHistoryActionFilter filter) {
|
String _actionLabel(ApprovalHistoryActionFilter filter) {
|
||||||
switch (filter) {
|
switch (filter) {
|
||||||
case ApprovalHistoryActionFilter.all:
|
case ApprovalHistoryActionFilter.all:
|
||||||
@@ -324,11 +358,25 @@ class _ApprovalHistoryTable extends StatelessWidget {
|
|||||||
required this.histories,
|
required this.histories,
|
||||||
required this.dateFormat,
|
required this.dateFormat,
|
||||||
required this.query,
|
required this.query,
|
||||||
|
required this.pagination,
|
||||||
|
required this.onPageChange,
|
||||||
|
required this.onPageSizeChange,
|
||||||
|
required this.isLoading,
|
||||||
|
required this.sortableColumns,
|
||||||
|
required this.sortState,
|
||||||
|
required this.onSortChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<ApprovalHistoryRecord> histories;
|
final List<ApprovalHistoryRecord> histories;
|
||||||
final DateFormat dateFormat;
|
final DateFormat dateFormat;
|
||||||
final String query;
|
final String query;
|
||||||
|
final SuperportTablePagination pagination;
|
||||||
|
final ValueChanged<int> onPageChange;
|
||||||
|
final ValueChanged<int> onPageSizeChange;
|
||||||
|
final bool isLoading;
|
||||||
|
final Set<int> sortableColumns;
|
||||||
|
final SuperportTableSortState? sortState;
|
||||||
|
final void Function(int columnIndex, bool ascending) onSortChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -402,6 +450,13 @@ class _ApprovalHistoryTable extends StatelessWidget {
|
|||||||
return const FixedTableSpanExtent(110);
|
return const FixedTableSpanExtent(110);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
pagination: pagination,
|
||||||
|
onPageChange: onPageChange,
|
||||||
|
onPageSizeChange: onPageSizeChange,
|
||||||
|
isLoading: isLoading,
|
||||||
|
sortableColumns: sortableColumns,
|
||||||
|
sortState: sortState,
|
||||||
|
onSortChanged: onSortChanged,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import '../../../../widgets/components/filter_bar.dart';
|
|||||||
import '../../../../widgets/components/superport_date_picker.dart';
|
import '../../../../widgets/components/superport_date_picker.dart';
|
||||||
import '../../../../widgets/components/superport_dialog.dart';
|
import '../../../../widgets/components/superport_dialog.dart';
|
||||||
import '../../../../widgets/components/superport_table.dart';
|
import '../../../../widgets/components/superport_table.dart';
|
||||||
import '../../../../widgets/spec_page.dart';
|
import '../../../../widgets/components/feature_disabled_placeholder.dart';
|
||||||
import '../../domain/entities/approval.dart';
|
import '../../domain/entities/approval.dart';
|
||||||
import '../../domain/entities/approval_template.dart';
|
import '../../domain/entities/approval_template.dart';
|
||||||
import '../../domain/repositories/approval_repository.dart';
|
import '../../domain/repositories/approval_repository.dart';
|
||||||
@@ -29,69 +29,28 @@ class ApprovalPage extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final enabled = Environment.flag('FEATURE_APPROVALS_ENABLED');
|
final enabled = Environment.flag('FEATURE_APPROVALS_ENABLED');
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
return SpecPage(
|
return AppLayout(
|
||||||
title: '결재 관리',
|
title: '결재 관리',
|
||||||
summary: '결재 요청 상태와 단계/이력을 모니터링합니다.',
|
subtitle: '결재 요청 상태와 단계/이력을 한 화면에서 확인합니다.',
|
||||||
trailing: ShadBadge(
|
breadcrumbs: const [
|
||||||
child: Padding(
|
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
AppBreadcrumbItem(label: '결재', path: '/approvals/requests'),
|
||||||
child: Row(
|
AppBreadcrumbItem(label: '결재 관리'),
|
||||||
mainAxisSize: MainAxisSize.min,
|
],
|
||||||
children: const [
|
actions: [
|
||||||
Icon(lucide.LucideIcons.info, size: 14),
|
Tooltip(
|
||||||
SizedBox(width: 6),
|
message: '백엔드 연동 후 사용 가능합니다.',
|
||||||
Text('비활성화 (백엔드 준비 중)'),
|
child: ShadButton(
|
||||||
],
|
onPressed: null,
|
||||||
),
|
leading: const Icon(lucide.LucideIcons.plus, size: 16),
|
||||||
),
|
child: const Text('신규 결재'),
|
||||||
),
|
|
||||||
sections: const [
|
|
||||||
SpecSection(
|
|
||||||
title: '입력 폼',
|
|
||||||
items: [
|
|
||||||
'트랜잭션번호 [Dropdown]',
|
|
||||||
'상신자 [ReadOnly]',
|
|
||||||
'결재상태 [Dropdown]',
|
|
||||||
'비고 [Textarea]',
|
|
||||||
],
|
|
||||||
),
|
|
||||||
SpecSection(
|
|
||||||
title: '상세 패널',
|
|
||||||
items: [
|
|
||||||
'개요 탭: 현재 상태/단계/요청·결정 일시',
|
|
||||||
'단계 탭: 단계 리스트 + 템플릿 불러오기',
|
|
||||||
'이력 탭: 행위/상태 변경/일시/비고',
|
|
||||||
],
|
|
||||||
),
|
|
||||||
SpecSection(
|
|
||||||
title: '테이블 리스트',
|
|
||||||
description: '1행 예시',
|
|
||||||
table: SpecTable(
|
|
||||||
columns: [
|
|
||||||
'번호',
|
|
||||||
'결재번호',
|
|
||||||
'트랜잭션번호',
|
|
||||||
'상태',
|
|
||||||
'상신자',
|
|
||||||
'요청일시',
|
|
||||||
'최종결정일시',
|
|
||||||
'비고',
|
|
||||||
],
|
|
||||||
rows: [
|
|
||||||
[
|
|
||||||
'1',
|
|
||||||
'AP-24001',
|
|
||||||
'TRX-202404-01',
|
|
||||||
'대기',
|
|
||||||
'김철수',
|
|
||||||
'2024-04-01 09:12',
|
|
||||||
'-',
|
|
||||||
'-',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
child: const FeatureDisabledPlaceholder(
|
||||||
|
title: '결재 관리 기능 준비 중',
|
||||||
|
description: '결재 API 연결이 완료되면 실제 결재 요청 목록과 단계 정보를 제공합니다.',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +140,10 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
|||||||
_approvalsResourcePath,
|
_approvalsResourcePath,
|
||||||
PermissionAction.approve,
|
PermissionAction.approve,
|
||||||
);
|
);
|
||||||
|
final canManageTemplates = permissionManager.can(
|
||||||
|
_approvalsResourcePath,
|
||||||
|
PermissionAction.edit,
|
||||||
|
);
|
||||||
|
|
||||||
if (templates.isNotEmpty && _selectedTemplateId == null) {
|
if (templates.isNotEmpty && _selectedTemplateId == null) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@@ -367,6 +330,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
|||||||
applyingTemplateId: applyingTemplateId,
|
applyingTemplateId: applyingTemplateId,
|
||||||
selectedTemplateId: _selectedTemplateId,
|
selectedTemplateId: _selectedTemplateId,
|
||||||
canPerformStepActions: canPerformStepActions,
|
canPerformStepActions: canPerformStepActions,
|
||||||
|
canApplyTemplate: canManageTemplates,
|
||||||
dateFormat: _dateTimeFormat,
|
dateFormat: _dateTimeFormat,
|
||||||
onRefresh: () {
|
onRefresh: () {
|
||||||
final id = selectedApproval?.id;
|
final id = selectedApproval?.id;
|
||||||
@@ -745,6 +709,7 @@ class _DetailSection extends StatelessWidget {
|
|||||||
required this.applyingTemplateId,
|
required this.applyingTemplateId,
|
||||||
required this.selectedTemplateId,
|
required this.selectedTemplateId,
|
||||||
required this.canPerformStepActions,
|
required this.canPerformStepActions,
|
||||||
|
required this.canApplyTemplate,
|
||||||
required this.dateFormat,
|
required this.dateFormat,
|
||||||
required this.onRefresh,
|
required this.onRefresh,
|
||||||
required this.onClose,
|
required this.onClose,
|
||||||
@@ -766,6 +731,7 @@ class _DetailSection extends StatelessWidget {
|
|||||||
final int? applyingTemplateId;
|
final int? applyingTemplateId;
|
||||||
final int? selectedTemplateId;
|
final int? selectedTemplateId;
|
||||||
final bool canPerformStepActions;
|
final bool canPerformStepActions;
|
||||||
|
final bool canApplyTemplate;
|
||||||
final intl.DateFormat dateFormat;
|
final intl.DateFormat dateFormat;
|
||||||
final VoidCallback onRefresh;
|
final VoidCallback onRefresh;
|
||||||
final VoidCallback? onClose;
|
final VoidCallback? onClose;
|
||||||
@@ -845,6 +811,7 @@ class _DetailSection extends StatelessWidget {
|
|||||||
applyingTemplateId: applyingTemplateId,
|
applyingTemplateId: applyingTemplateId,
|
||||||
selectedTemplateId: selectedTemplateId,
|
selectedTemplateId: selectedTemplateId,
|
||||||
canPerformStepActions: canPerformStepActions,
|
canPerformStepActions: canPerformStepActions,
|
||||||
|
canApplyTemplate: canApplyTemplate,
|
||||||
onSelectTemplate: onSelectTemplate,
|
onSelectTemplate: onSelectTemplate,
|
||||||
onApplyTemplate: onApplyTemplate,
|
onApplyTemplate: onApplyTemplate,
|
||||||
onReloadTemplates: onReloadTemplates,
|
onReloadTemplates: onReloadTemplates,
|
||||||
@@ -942,6 +909,7 @@ class _StepTab extends StatelessWidget {
|
|||||||
required this.applyingTemplateId,
|
required this.applyingTemplateId,
|
||||||
required this.selectedTemplateId,
|
required this.selectedTemplateId,
|
||||||
required this.canPerformStepActions,
|
required this.canPerformStepActions,
|
||||||
|
required this.canApplyTemplate,
|
||||||
required this.onSelectTemplate,
|
required this.onSelectTemplate,
|
||||||
required this.onApplyTemplate,
|
required this.onApplyTemplate,
|
||||||
required this.onReloadTemplates,
|
required this.onReloadTemplates,
|
||||||
@@ -961,6 +929,7 @@ class _StepTab extends StatelessWidget {
|
|||||||
final int? applyingTemplateId;
|
final int? applyingTemplateId;
|
||||||
final int? selectedTemplateId;
|
final int? selectedTemplateId;
|
||||||
final bool canPerformStepActions;
|
final bool canPerformStepActions;
|
||||||
|
final bool canApplyTemplate;
|
||||||
final void Function(int?) onSelectTemplate;
|
final void Function(int?) onSelectTemplate;
|
||||||
final void Function(int templateId) onApplyTemplate;
|
final void Function(int templateId) onApplyTemplate;
|
||||||
final VoidCallback onReloadTemplates;
|
final VoidCallback onReloadTemplates;
|
||||||
@@ -980,12 +949,20 @@ class _StepTab extends StatelessWidget {
|
|||||||
selectedTemplateId: selectedTemplateId,
|
selectedTemplateId: selectedTemplateId,
|
||||||
isApplyingTemplate: isApplyingTemplate,
|
isApplyingTemplate: isApplyingTemplate,
|
||||||
applyingTemplateId: applyingTemplateId,
|
applyingTemplateId: applyingTemplateId,
|
||||||
canApplyTemplate: canPerformStepActions,
|
canApplyTemplate: canApplyTemplate,
|
||||||
onSelectTemplate: onSelectTemplate,
|
onSelectTemplate: onSelectTemplate,
|
||||||
onApplyTemplate: onApplyTemplate,
|
onApplyTemplate: onApplyTemplate,
|
||||||
onReload: onReloadTemplates,
|
onReload: onReloadTemplates,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (!canApplyTemplate)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Text(
|
||||||
|
'템플릿 적용 권한이 없어 단계 구성을 변경할 수 없습니다.',
|
||||||
|
style: theme.textTheme.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
if (!isLoadingTemplates && templates.isEmpty)
|
if (!isLoadingTemplates && templates.isEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
|||||||
118
lib/features/approvals/shared/approver_catalog.dart
Normal file
118
lib/features/approvals/shared/approver_catalog.dart
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// 결재 승인자(approver)를 자동완성으로 검색하기 위한 카탈로그 항목.
|
||||||
|
class ApprovalApproverCatalogItem {
|
||||||
|
const ApprovalApproverCatalogItem({
|
||||||
|
required this.id,
|
||||||
|
required this.employeeNo,
|
||||||
|
required this.name,
|
||||||
|
required this.team,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String employeeNo;
|
||||||
|
final String name;
|
||||||
|
final String team;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normalize(String value) {
|
||||||
|
return value.toLowerCase().replaceAll(RegExp(r'[^a-z0-9가-힣]'), '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 결재용 승인자 카탈로그.
|
||||||
|
///
|
||||||
|
/// - API 연동 전까지 고정된 데이터를 사용한다.
|
||||||
|
class ApprovalApproverCatalog {
|
||||||
|
static final List<ApprovalApproverCatalogItem> items = List.unmodifiable([
|
||||||
|
const ApprovalApproverCatalogItem(
|
||||||
|
id: 101,
|
||||||
|
employeeNo: 'EMP101',
|
||||||
|
name: '김결재',
|
||||||
|
team: '물류운영팀',
|
||||||
|
),
|
||||||
|
const ApprovalApproverCatalogItem(
|
||||||
|
id: 102,
|
||||||
|
employeeNo: 'EMP102',
|
||||||
|
name: '박승인',
|
||||||
|
team: '재무팀',
|
||||||
|
),
|
||||||
|
const ApprovalApproverCatalogItem(
|
||||||
|
id: 103,
|
||||||
|
employeeNo: 'EMP103',
|
||||||
|
name: '이반려',
|
||||||
|
team: '품질보증팀',
|
||||||
|
),
|
||||||
|
const ApprovalApproverCatalogItem(
|
||||||
|
id: 104,
|
||||||
|
employeeNo: 'EMP104',
|
||||||
|
name: '최리뷰',
|
||||||
|
team: '운영혁신팀',
|
||||||
|
),
|
||||||
|
const ApprovalApproverCatalogItem(
|
||||||
|
id: 105,
|
||||||
|
employeeNo: 'EMP105',
|
||||||
|
name: '정검토',
|
||||||
|
team: '구매팀',
|
||||||
|
),
|
||||||
|
const ApprovalApproverCatalogItem(
|
||||||
|
id: 106,
|
||||||
|
employeeNo: 'EMP106',
|
||||||
|
name: '오승훈',
|
||||||
|
team: '영업지원팀',
|
||||||
|
),
|
||||||
|
const ApprovalApproverCatalogItem(
|
||||||
|
id: 107,
|
||||||
|
employeeNo: 'EMP107',
|
||||||
|
name: '유컨펌',
|
||||||
|
team: '총무팀',
|
||||||
|
),
|
||||||
|
const ApprovalApproverCatalogItem(
|
||||||
|
id: 108,
|
||||||
|
employeeNo: 'EMP108',
|
||||||
|
name: '문서결',
|
||||||
|
team: '경영기획팀',
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
static final Map<int, ApprovalApproverCatalogItem> _byId = {
|
||||||
|
for (final item in items) item.id: item,
|
||||||
|
};
|
||||||
|
|
||||||
|
static final Map<String, ApprovalApproverCatalogItem> _byEmployeeNo = {
|
||||||
|
for (final item in items) item.employeeNo.toLowerCase(): item,
|
||||||
|
};
|
||||||
|
|
||||||
|
static ApprovalApproverCatalogItem? byId(int? id) =>
|
||||||
|
id == null ? null : _byId[id];
|
||||||
|
|
||||||
|
static ApprovalApproverCatalogItem? byEmployeeNo(String? employeeNo) {
|
||||||
|
if (employeeNo == null) return null;
|
||||||
|
return _byEmployeeNo[employeeNo.toLowerCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<ApprovalApproverCatalogItem> filter(String query) {
|
||||||
|
final normalized = _normalize(query);
|
||||||
|
if (normalized.isEmpty) {
|
||||||
|
return items.take(10).toList();
|
||||||
|
}
|
||||||
|
final lower = query.toLowerCase();
|
||||||
|
return [
|
||||||
|
for (final item in items)
|
||||||
|
if (_normalize(item.name).contains(normalized) ||
|
||||||
|
item.employeeNo.toLowerCase().contains(lower) ||
|
||||||
|
item.team.toLowerCase().contains(lower) ||
|
||||||
|
item.id.toString().contains(lower))
|
||||||
|
item,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 자동완성 추천이 없을 때 보여줄 위젯.
|
||||||
|
Widget buildEmptyApproverResult(TextTheme textTheme) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||||
|
child: Text('일치하는 승인자를 찾지 못했습니다.', style: textTheme.bodySmall),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
|
import '../approver_catalog.dart';
|
||||||
|
|
||||||
|
/// 승인자 자동완성 필드.
|
||||||
|
///
|
||||||
|
/// - 사용자가 이름/사번으로 검색하면 일치하는 승인자를 제안한다.
|
||||||
|
/// - 항목을 선택하면 `idController`에 승인자 ID가 채워진다.
|
||||||
|
class ApprovalApproverAutocompleteField extends StatefulWidget {
|
||||||
|
const ApprovalApproverAutocompleteField({
|
||||||
|
super.key,
|
||||||
|
required this.idController,
|
||||||
|
this.hintText,
|
||||||
|
this.onSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController idController;
|
||||||
|
final String? hintText;
|
||||||
|
final void Function(ApprovalApproverCatalogItem?)? onSelected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ApprovalApproverAutocompleteField> createState() =>
|
||||||
|
_ApprovalApproverAutocompleteFieldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ApprovalApproverAutocompleteFieldState
|
||||||
|
extends State<ApprovalApproverAutocompleteField> {
|
||||||
|
late final TextEditingController _textController;
|
||||||
|
late final FocusNode _focusNode;
|
||||||
|
ApprovalApproverCatalogItem? _selected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_textController = TextEditingController();
|
||||||
|
_focusNode = FocusNode();
|
||||||
|
_focusNode.addListener(_handleFocusChange);
|
||||||
|
_syncFromId();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncFromId() {
|
||||||
|
final idText = widget.idController.text.trim();
|
||||||
|
final id = int.tryParse(idText);
|
||||||
|
final match = ApprovalApproverCatalog.byId(id);
|
||||||
|
if (match != null) {
|
||||||
|
_selected = match;
|
||||||
|
_textController.text = _displayLabel(match);
|
||||||
|
} else if (id != null) {
|
||||||
|
_selected = null;
|
||||||
|
_textController.text = '직접 입력: $id';
|
||||||
|
} else {
|
||||||
|
_selected = null;
|
||||||
|
_textController.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Iterable<ApprovalApproverCatalogItem> _options(String query) {
|
||||||
|
return ApprovalApproverCatalog.filter(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSelected(ApprovalApproverCatalogItem item) {
|
||||||
|
setState(() {
|
||||||
|
_selected = item;
|
||||||
|
widget.idController.text = item.id.toString();
|
||||||
|
_textController.text = _displayLabel(item);
|
||||||
|
widget.onSelected?.call(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleCleared() {
|
||||||
|
setState(() {
|
||||||
|
_selected = null;
|
||||||
|
widget.idController.clear();
|
||||||
|
_textController.clear();
|
||||||
|
widget.onSelected?.call(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String _displayLabel(ApprovalApproverCatalogItem item) {
|
||||||
|
return '${item.name} (${item.employeeNo}) · ${item.team}';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyManualEntry(String value) {
|
||||||
|
final trimmed = value.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
_handleCleared();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final manualId = int.tryParse(trimmed.replaceAll(RegExp(r'[^0-9]'), ''));
|
||||||
|
if (manualId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final match = ApprovalApproverCatalog.byId(manualId);
|
||||||
|
if (match != null) {
|
||||||
|
_handleSelected(match);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_selected = null;
|
||||||
|
widget.idController.text = manualId.toString();
|
||||||
|
_textController.text = '직접 입력: $manualId';
|
||||||
|
widget.onSelected?.call(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleFocusChange() {
|
||||||
|
if (!_focusNode.hasFocus) {
|
||||||
|
_applyManualEntry(_textController.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = ShadTheme.of(context);
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
return RawAutocomplete<ApprovalApproverCatalogItem>(
|
||||||
|
textEditingController: _textController,
|
||||||
|
focusNode: _focusNode,
|
||||||
|
optionsBuilder: (textEditingValue) {
|
||||||
|
final text = textEditingValue.text.trim();
|
||||||
|
if (text.isEmpty) {
|
||||||
|
return const Iterable<ApprovalApproverCatalogItem>.empty();
|
||||||
|
}
|
||||||
|
return _options(text);
|
||||||
|
},
|
||||||
|
displayStringForOption: _displayLabel,
|
||||||
|
onSelected: _handleSelected,
|
||||||
|
fieldViewBuilder:
|
||||||
|
(context, textController, focusNode, onFieldSubmitted) {
|
||||||
|
return ShadInput(
|
||||||
|
controller: textController,
|
||||||
|
focusNode: focusNode,
|
||||||
|
placeholder: Text(widget.hintText ?? '승인자 이름 또는 사번 검색'),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value.isEmpty) {
|
||||||
|
_handleCleared();
|
||||||
|
} else if (_selected != null &&
|
||||||
|
value != _displayLabel(_selected!)) {
|
||||||
|
setState(() {
|
||||||
|
_selected = null;
|
||||||
|
widget.idController.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSubmitted: (_) {
|
||||||
|
_applyManualEntry(textController.text);
|
||||||
|
onFieldSubmitted();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
optionsViewBuilder: (context, onSelected, options) {
|
||||||
|
if (options.isEmpty) {
|
||||||
|
return Align(
|
||||||
|
alignment: AlignmentDirectional.topStart,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: constraints.maxWidth,
|
||||||
|
maxHeight: 220,
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
elevation: 6,
|
||||||
|
color: theme.colorScheme.background,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
side: BorderSide(color: theme.colorScheme.border),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||||
|
child: Text(
|
||||||
|
'일치하는 승인자를 찾지 못했습니다.',
|
||||||
|
style: theme.textTheme.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Align(
|
||||||
|
alignment: AlignmentDirectional.topStart,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: constraints.maxWidth,
|
||||||
|
maxHeight: 260,
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
elevation: 6,
|
||||||
|
color: theme.colorScheme.background,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
side: BorderSide(color: theme.colorScheme.border),
|
||||||
|
),
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
itemCount: options.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final option = options.elementAt(index);
|
||||||
|
return InkWell(
|
||||||
|
onTap: () => onSelected(option),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${option.name} · ${option.team}',
|
||||||
|
style: theme.textTheme.p,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'ID ${option.id} · ${option.employeeNo}',
|
||||||
|
style: theme.textTheme.muted.copyWith(
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_textController.dispose();
|
||||||
|
_focusNode
|
||||||
|
..removeListener(_handleFocusChange)
|
||||||
|
..dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import '../../../../../core/constants/app_sections.dart';
|
|||||||
import '../../../../../widgets/app_layout.dart';
|
import '../../../../../widgets/app_layout.dart';
|
||||||
import '../../../../../widgets/components/filter_bar.dart';
|
import '../../../../../widgets/components/filter_bar.dart';
|
||||||
import '../../../../../widgets/components/superport_dialog.dart';
|
import '../../../../../widgets/components/superport_dialog.dart';
|
||||||
import '../../../../../widgets/spec_page.dart';
|
import '../../../../../widgets/components/feature_disabled_placeholder.dart';
|
||||||
import '../controllers/approval_step_controller.dart';
|
import '../controllers/approval_step_controller.dart';
|
||||||
import '../../domain/entities/approval_step_input.dart';
|
import '../../domain/entities/approval_step_input.dart';
|
||||||
import '../../domain/entities/approval_step_record.dart';
|
import '../../domain/entities/approval_step_record.dart';
|
||||||
@@ -21,53 +21,28 @@ class ApprovalStepPage extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final enabled = Environment.flag('FEATURE_APPROVALS_ENABLED');
|
final enabled = Environment.flag('FEATURE_APPROVALS_ENABLED');
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
return const SpecPage(
|
return AppLayout(
|
||||||
title: '결재 단계 관리',
|
title: '결재 단계 관리',
|
||||||
summary: '결재 단계 순서와 승인자를 구성합니다.',
|
subtitle: '결재 요청별 단계 현황을 구성하고 승인자를 배정합니다.',
|
||||||
sections: [
|
breadcrumbs: const [
|
||||||
SpecSection(
|
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
|
||||||
title: '입력 폼',
|
AppBreadcrumbItem(label: '결재', path: '/approvals/steps'),
|
||||||
items: [
|
AppBreadcrumbItem(label: '결재 단계'),
|
||||||
'결재ID [Dropdown]',
|
],
|
||||||
'단계순서 [Number]',
|
actions: [
|
||||||
'승인자 [Dropdown]',
|
Tooltip(
|
||||||
'단계상태 [Dropdown]',
|
message: 'API 연동 이후 단계 추가가 가능합니다.',
|
||||||
'비고 [Text]',
|
child: ShadButton(
|
||||||
],
|
onPressed: null,
|
||||||
),
|
leading: const Icon(lucide.LucideIcons.plus, size: 16),
|
||||||
SpecSection(
|
child: const Text('단계 추가'),
|
||||||
title: '수정 폼',
|
|
||||||
items: ['결재ID [ReadOnly]', '단계순서 [ReadOnly]'],
|
|
||||||
),
|
|
||||||
SpecSection(
|
|
||||||
title: '테이블 리스트',
|
|
||||||
description: '1행 예시',
|
|
||||||
table: SpecTable(
|
|
||||||
columns: [
|
|
||||||
'번호',
|
|
||||||
'결재ID',
|
|
||||||
'단계순서',
|
|
||||||
'승인자',
|
|
||||||
'상태',
|
|
||||||
'배정일시',
|
|
||||||
'결정일시',
|
|
||||||
'비고',
|
|
||||||
],
|
|
||||||
rows: [
|
|
||||||
[
|
|
||||||
'1',
|
|
||||||
'APP-20240301-001',
|
|
||||||
'1',
|
|
||||||
'최관리',
|
|
||||||
'승인대기',
|
|
||||||
'2024-03-01 09:00',
|
|
||||||
'-',
|
|
||||||
'-',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
child: const FeatureDisabledPlaceholder(
|
||||||
|
title: '결재 단계 기능 준비 중',
|
||||||
|
description: '결재 단계 API 연동을 기다리는 중입니다. 연동 후 단계 생성/수정/삭제 기능이 활성화됩니다.',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class ApprovalTemplateController extends ChangeNotifier {
|
|||||||
String _query = '';
|
String _query = '';
|
||||||
ApprovalTemplateStatusFilter _statusFilter = ApprovalTemplateStatusFilter.all;
|
ApprovalTemplateStatusFilter _statusFilter = ApprovalTemplateStatusFilter.all;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
int _pageSize = 20;
|
||||||
|
|
||||||
PaginatedResult<ApprovalTemplate>? get result => _result;
|
PaginatedResult<ApprovalTemplate>? get result => _result;
|
||||||
bool get isLoading => _isLoading;
|
bool get isLoading => _isLoading;
|
||||||
@@ -28,6 +29,7 @@ class ApprovalTemplateController extends ChangeNotifier {
|
|||||||
String get query => _query;
|
String get query => _query;
|
||||||
ApprovalTemplateStatusFilter get statusFilter => _statusFilter;
|
ApprovalTemplateStatusFilter get statusFilter => _statusFilter;
|
||||||
String? get errorMessage => _errorMessage;
|
String? get errorMessage => _errorMessage;
|
||||||
|
int get pageSize => _result?.pageSize ?? _pageSize;
|
||||||
|
|
||||||
Future<void> fetch({int page = 1}) async {
|
Future<void> fetch({int page = 1}) async {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
@@ -42,11 +44,12 @@ class ApprovalTemplateController extends ChangeNotifier {
|
|||||||
};
|
};
|
||||||
final response = await _repository.list(
|
final response = await _repository.list(
|
||||||
page: page,
|
page: page,
|
||||||
pageSize: _result?.pageSize ?? 20,
|
pageSize: _pageSize,
|
||||||
query: sanitizedQuery.isEmpty ? null : sanitizedQuery,
|
query: sanitizedQuery.isEmpty ? null : sanitizedQuery,
|
||||||
isActive: isActive,
|
isActive: isActive,
|
||||||
);
|
);
|
||||||
_result = response;
|
_result = response;
|
||||||
|
_pageSize = response.pageSize;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_errorMessage = e.toString();
|
_errorMessage = e.toString();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -160,6 +163,14 @@ class ApprovalTemplateController extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void updatePageSize(int value) {
|
||||||
|
if (value <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_pageSize = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
void _setSubmitting(bool value) {
|
void _setSubmitting(bool value) {
|
||||||
_isSubmitting = value;
|
_isSubmitting = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import '../../../../../widgets/app_layout.dart';
|
|||||||
import '../../../../../widgets/components/filter_bar.dart';
|
import '../../../../../widgets/components/filter_bar.dart';
|
||||||
import '../../../../../widgets/components/superport_table.dart';
|
import '../../../../../widgets/components/superport_table.dart';
|
||||||
import '../../../../../widgets/components/superport_dialog.dart';
|
import '../../../../../widgets/components/superport_dialog.dart';
|
||||||
import '../../../../../widgets/spec_page.dart';
|
import '../../../../../widgets/components/feature_disabled_placeholder.dart';
|
||||||
|
import '../../../shared/widgets/approver_autocomplete_field.dart';
|
||||||
import '../../../domain/entities/approval_template.dart';
|
import '../../../domain/entities/approval_template.dart';
|
||||||
import '../../../domain/repositories/approval_template_repository.dart';
|
import '../../../domain/repositories/approval_template_repository.dart';
|
||||||
import '../controllers/approval_template_controller.dart';
|
import '../controllers/approval_template_controller.dart';
|
||||||
@@ -21,43 +22,28 @@ class ApprovalTemplatePage extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final enabled = Environment.flag('FEATURE_APPROVALS_ENABLED');
|
final enabled = Environment.flag('FEATURE_APPROVALS_ENABLED');
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
return const SpecPage(
|
return AppLayout(
|
||||||
title: '결재 템플릿 관리',
|
title: '결재 템플릿 관리',
|
||||||
summary: '반복되는 결재 단계를 템플릿으로 구성합니다.',
|
subtitle: '반복되는 결재 단계를 템플릿으로 저장하고 재사용합니다.',
|
||||||
sections: [
|
breadcrumbs: const [
|
||||||
SpecSection(
|
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
|
||||||
title: '입력 폼',
|
AppBreadcrumbItem(label: '결재', path: '/approvals/templates'),
|
||||||
items: [
|
AppBreadcrumbItem(label: '결재 템플릿'),
|
||||||
'템플릿코드 [Text]',
|
],
|
||||||
'템플릿명 [Text]',
|
actions: [
|
||||||
'설명 [Text]',
|
Tooltip(
|
||||||
'사용여부 [Switch]',
|
message: '템플릿 생성은 서버 연동 후 지원됩니다.',
|
||||||
'비고 [Textarea]',
|
child: ShadButton(
|
||||||
'단계 추가: 순서 [Number], 승인자ID [Number]',
|
onPressed: null,
|
||||||
],
|
leading: const Icon(lucide.LucideIcons.plus, size: 16),
|
||||||
),
|
child: const Text('템플릿 생성'),
|
||||||
SpecSection(
|
|
||||||
title: '수정 폼',
|
|
||||||
items: ['템플릿코드 [ReadOnly]', '작성자 [ReadOnly]'],
|
|
||||||
),
|
|
||||||
SpecSection(
|
|
||||||
title: '테이블 리스트',
|
|
||||||
description: '1행 예시',
|
|
||||||
table: SpecTable(
|
|
||||||
columns: ['번호', '템플릿코드', '템플릿명', '설명', '사용여부', '변경일시'],
|
|
||||||
rows: [
|
|
||||||
[
|
|
||||||
'1',
|
|
||||||
'AP_INBOUND',
|
|
||||||
'입고 결재 기본',
|
|
||||||
'입고 2단계',
|
|
||||||
'Y',
|
|
||||||
'2024-04-01 09:00',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
child: const FeatureDisabledPlaceholder(
|
||||||
|
title: '결재 템플릿 기능 준비 중',
|
||||||
|
description: '백엔드 템플릿 API가 연동되면 템플릿 생성과 단계 구성 기능이 활성화됩니다.',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +66,7 @@ class _ApprovalTemplateEnabledPageState
|
|||||||
final FocusNode _searchFocus = FocusNode();
|
final FocusNode _searchFocus = FocusNode();
|
||||||
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm');
|
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm');
|
||||||
String? _lastError;
|
String? _lastError;
|
||||||
|
static const _pageSizeOptions = [10, 20, 50];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -126,10 +113,6 @@ class _ApprovalTemplateEnabledPageState
|
|||||||
final totalPages = result == null || result.pageSize == 0
|
final totalPages = result == null || result.pageSize == 0
|
||||||
? 1
|
? 1
|
||||||
: (result.total / result.pageSize).ceil().clamp(1, 9999);
|
: (result.total / result.pageSize).ceil().clamp(1, 9999);
|
||||||
final hasNext = result == null
|
|
||||||
? false
|
|
||||||
: (result.page * result.pageSize) < result.total;
|
|
||||||
|
|
||||||
final showReset =
|
final showReset =
|
||||||
_searchController.text.trim().isNotEmpty ||
|
_searchController.text.trim().isNotEmpty ||
|
||||||
_controller.statusFilter != ApprovalTemplateStatusFilter.all;
|
_controller.statusFilter != ApprovalTemplateStatusFilter.all;
|
||||||
@@ -305,42 +288,19 @@ class _ApprovalTemplateEnabledPageState
|
|||||||
return const FixedTableSpanExtent(140);
|
return const FixedTableSpanExtent(140);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
pagination: SuperportTablePagination(
|
||||||
const SizedBox(height: 16),
|
currentPage: currentPage,
|
||||||
Row(
|
totalPages: totalPages,
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
totalItems: totalCount,
|
||||||
children: [
|
pageSize: _controller.pageSize,
|
||||||
Text('총 $totalCount건', style: theme.textTheme.small),
|
pageSizeOptions: _pageSizeOptions,
|
||||||
Row(
|
),
|
||||||
children: [
|
onPageChange: (page) => _controller.fetch(page: page),
|
||||||
ShadButton.outline(
|
onPageSizeChange: (size) {
|
||||||
size: ShadButtonSize.sm,
|
_controller.updatePageSize(size);
|
||||||
onPressed:
|
_controller.fetch(page: 1);
|
||||||
_controller.isLoading || currentPage <= 1
|
},
|
||||||
? null
|
isLoading: _controller.isLoading,
|
||||||
: () => _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('다음'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(
|
|
||||||
'페이지 $currentPage / $totalPages',
|
|
||||||
style: theme.textTheme.small,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -500,11 +460,7 @@ class _ApprovalTemplateEnabledPageState
|
|||||||
modalSetState?.call(() => isSaving = true);
|
modalSetState?.call(() => isSaving = true);
|
||||||
|
|
||||||
final success = isEdit && existingTemplate != null
|
final success = isEdit && existingTemplate != null
|
||||||
? await _controller.update(
|
? await _controller.update(existingTemplate.id, input, stepInputs)
|
||||||
existingTemplate.id,
|
|
||||||
input,
|
|
||||||
stepInputs,
|
|
||||||
)
|
|
||||||
: await _controller.create(input, stepInputs);
|
: await _controller.create(input, stepInputs);
|
||||||
if (success != null && mounted) {
|
if (success != null && mounted) {
|
||||||
Navigator.of(context).pop(true);
|
Navigator.of(context).pop(true);
|
||||||
@@ -517,7 +473,7 @@ class _ApprovalTemplateEnabledPageState
|
|||||||
context: context,
|
context: context,
|
||||||
title: isEdit ? '템플릿 수정' : '템플릿 생성',
|
title: isEdit ? '템플릿 수정' : '템플릿 생성',
|
||||||
barrierDismissible: !isSaving,
|
barrierDismissible: !isSaving,
|
||||||
onSubmit: handleSubmit,
|
onSubmit: handleSubmit,
|
||||||
body: StatefulBuilder(
|
body: StatefulBuilder(
|
||||||
builder: (dialogContext, setModalState) {
|
builder: (dialogContext, setModalState) {
|
||||||
modalSetState = setModalState;
|
modalSetState = setModalState;
|
||||||
@@ -593,6 +549,7 @@ class _ApprovalTemplateEnabledPageState
|
|||||||
field: steps[index],
|
field: steps[index],
|
||||||
index: index,
|
index: index,
|
||||||
isEdit: isEdit,
|
isEdit: isEdit,
|
||||||
|
isDisabled: isSaving,
|
||||||
onRemove: steps.length <= 1 || isSaving
|
onRemove: steps.length <= 1 || isSaving
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
@@ -763,12 +720,14 @@ class _StepEditorRow extends StatelessWidget {
|
|||||||
required this.field,
|
required this.field,
|
||||||
required this.index,
|
required this.index,
|
||||||
required this.isEdit,
|
required this.isEdit,
|
||||||
|
required this.isDisabled,
|
||||||
required this.onRemove,
|
required this.onRemove,
|
||||||
});
|
});
|
||||||
|
|
||||||
final _TemplateStepField field;
|
final _TemplateStepField field;
|
||||||
final int index;
|
final int index;
|
||||||
final bool isEdit;
|
final bool isEdit;
|
||||||
|
final bool isDisabled;
|
||||||
final VoidCallback? onRemove;
|
final VoidCallback? onRemove;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -793,14 +752,18 @@ class _StepEditorRow extends StatelessWidget {
|
|||||||
controller: field.orderController,
|
controller: field.orderController,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
placeholder: const Text('단계 순서'),
|
placeholder: const Text('단계 순서'),
|
||||||
|
enabled: !isDisabled,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ShadInput(
|
child: IgnorePointer(
|
||||||
controller: field.approverController,
|
ignoring: isDisabled,
|
||||||
keyboardType: TextInputType.number,
|
child: ApprovalApproverAutocompleteField(
|
||||||
placeholder: const Text('승인자 ID'),
|
idController: field.approverController,
|
||||||
|
hintText: '승인자 검색',
|
||||||
|
onSelected: (_) {},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -813,11 +776,14 @@ class _StepEditorRow extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
ShadTextarea(
|
IgnorePointer(
|
||||||
controller: field.noteController,
|
ignoring: isDisabled,
|
||||||
minHeight: 60,
|
child: ShadTextarea(
|
||||||
maxHeight: 160,
|
controller: field.noteController,
|
||||||
placeholder: const Text('비고 (선택)'),
|
minHeight: 60,
|
||||||
|
maxHeight: 160,
|
||||||
|
placeholder: const Text('비고 (선택)'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (isEdit && field.id != null)
|
if (isEdit && field.id != null)
|
||||||
Padding(
|
Padding(
|
||||||
|
|||||||
@@ -837,6 +837,8 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
String? writerError;
|
String? writerError;
|
||||||
|
String? warehouseError;
|
||||||
|
String? statusError;
|
||||||
String? headerNotice;
|
String? headerNotice;
|
||||||
void Function(VoidCallback fn)? refreshForm;
|
void Function(VoidCallback fn)? refreshForm;
|
||||||
|
|
||||||
@@ -847,10 +849,14 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
void handleSubmit() {
|
void handleSubmit() {
|
||||||
final validationResult = _validateInboundForm(
|
final validationResult = _validateInboundForm(
|
||||||
writerController: writerController,
|
writerController: writerController,
|
||||||
|
warehouseValue: warehouseController.text,
|
||||||
|
statusValue: statusValue.value,
|
||||||
drafts: drafts,
|
drafts: drafts,
|
||||||
lineErrors: lineErrors,
|
lineErrors: lineErrors,
|
||||||
);
|
);
|
||||||
writerError = validationResult.writerError;
|
writerError = validationResult.writerError;
|
||||||
|
warehouseError = validationResult.warehouseError;
|
||||||
|
statusError = validationResult.statusError;
|
||||||
headerNotice = validationResult.headerNotice;
|
headerNotice = validationResult.headerNotice;
|
||||||
refreshForm?.call(() {});
|
refreshForm?.call(() {});
|
||||||
|
|
||||||
@@ -871,6 +877,7 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
items.sort((a, b) => a.product.compareTo(b.product));
|
||||||
result = InboundRecord(
|
result = InboundRecord(
|
||||||
number: initial?.number ?? _generateInboundNumber(processedAt.value),
|
number: initial?.number ?? _generateInboundNumber(processedAt.value),
|
||||||
transactionNumber:
|
transactionNumber:
|
||||||
@@ -936,6 +943,7 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
child: SuperportFormField(
|
child: SuperportFormField(
|
||||||
label: '창고',
|
label: '창고',
|
||||||
required: true,
|
required: true,
|
||||||
|
errorText: warehouseError,
|
||||||
child: ShadSelect<String>(
|
child: ShadSelect<String>(
|
||||||
initialValue: warehouseController.text,
|
initialValue: warehouseController.text,
|
||||||
selectedOptionBuilder: (context, value) =>
|
selectedOptionBuilder: (context, value) =>
|
||||||
@@ -943,7 +951,11 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
warehouseController.text = value;
|
warehouseController.text = value;
|
||||||
setState(() {});
|
setState(() {
|
||||||
|
if (warehouseError != null) {
|
||||||
|
warehouseError = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
options: _warehouseOptions
|
options: _warehouseOptions
|
||||||
@@ -962,6 +974,7 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
child: SuperportFormField(
|
child: SuperportFormField(
|
||||||
label: '상태',
|
label: '상태',
|
||||||
required: true,
|
required: true,
|
||||||
|
errorText: statusError,
|
||||||
child: ShadSelect<String>(
|
child: ShadSelect<String>(
|
||||||
initialValue: statusValue.value,
|
initialValue: statusValue.value,
|
||||||
selectedOptionBuilder: (context, value) =>
|
selectedOptionBuilder: (context, value) =>
|
||||||
@@ -969,7 +982,11 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
statusValue.value = value;
|
statusValue.value = value;
|
||||||
setState(() {});
|
setState(() {
|
||||||
|
if (statusError != null) {
|
||||||
|
statusError = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled: initial?.status != '승인완료',
|
enabled: initial?.status != '승인완료',
|
||||||
@@ -1525,11 +1542,15 @@ class _LineItemFieldErrors {
|
|||||||
|
|
||||||
_InboundFormValidation _validateInboundForm({
|
_InboundFormValidation _validateInboundForm({
|
||||||
required TextEditingController writerController,
|
required TextEditingController writerController,
|
||||||
|
required String warehouseValue,
|
||||||
|
required String statusValue,
|
||||||
required List<_LineItemDraft> drafts,
|
required List<_LineItemDraft> drafts,
|
||||||
required Map<_LineItemDraft, _LineItemFieldErrors> lineErrors,
|
required Map<_LineItemDraft, _LineItemFieldErrors> lineErrors,
|
||||||
}) {
|
}) {
|
||||||
var isValid = true;
|
var isValid = true;
|
||||||
String? writerError;
|
String? writerError;
|
||||||
|
String? warehouseError;
|
||||||
|
String? statusError;
|
||||||
String? headerNotice;
|
String? headerNotice;
|
||||||
|
|
||||||
if (writerController.text.trim().isEmpty) {
|
if (writerController.text.trim().isEmpty) {
|
||||||
@@ -1537,12 +1558,24 @@ _InboundFormValidation _validateInboundForm({
|
|||||||
isValid = false;
|
isValid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (warehouseValue.trim().isEmpty) {
|
||||||
|
warehouseError = '창고를 선택하세요.';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusValue.trim().isEmpty) {
|
||||||
|
statusError = '상태를 선택하세요.';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
var hasLineError = false;
|
var hasLineError = false;
|
||||||
|
final seenProductKeys = <String>{};
|
||||||
for (final draft in drafts) {
|
for (final draft in drafts) {
|
||||||
final errors = lineErrors.putIfAbsent(draft, _LineItemFieldErrors.empty);
|
final errors = lineErrors.putIfAbsent(draft, _LineItemFieldErrors.empty);
|
||||||
errors.clearAll();
|
errors.clearAll();
|
||||||
|
|
||||||
if (draft.product.text.trim().isEmpty) {
|
final productText = draft.product.text.trim();
|
||||||
|
if (productText.isEmpty) {
|
||||||
errors.product = '제품을 입력하세요.';
|
errors.product = '제품을 입력하세요.';
|
||||||
hasLineError = true;
|
hasLineError = true;
|
||||||
isValid = false;
|
isValid = false;
|
||||||
@@ -1552,8 +1585,15 @@ _InboundFormValidation _validateInboundForm({
|
|||||||
isValid = false;
|
isValid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final productKey = (draft.catalogMatch?.code ?? productText.toLowerCase());
|
||||||
|
if (productKey.isNotEmpty && !seenProductKeys.add(productKey)) {
|
||||||
|
errors.product = '동일 제품이 중복되었습니다.';
|
||||||
|
hasLineError = true;
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
final quantity = int.tryParse(
|
final quantity = int.tryParse(
|
||||||
draft.quantity.text.trim() == '' ? '0' : draft.quantity.text.trim(),
|
draft.quantity.text.trim().isEmpty ? '0' : draft.quantity.text.trim(),
|
||||||
);
|
);
|
||||||
if (quantity == null || quantity < 1) {
|
if (quantity == null || quantity < 1) {
|
||||||
errors.quantity = '수량은 1 이상 정수여야 합니다.';
|
errors.quantity = '수량은 1 이상 정수여야 합니다.';
|
||||||
@@ -1576,6 +1616,8 @@ _InboundFormValidation _validateInboundForm({
|
|||||||
return _InboundFormValidation(
|
return _InboundFormValidation(
|
||||||
isValid: isValid,
|
isValid: isValid,
|
||||||
writerError: writerError,
|
writerError: writerError,
|
||||||
|
warehouseError: warehouseError,
|
||||||
|
statusError: statusError,
|
||||||
headerNotice: headerNotice,
|
headerNotice: headerNotice,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1589,11 +1631,15 @@ class _InboundFormValidation {
|
|||||||
const _InboundFormValidation({
|
const _InboundFormValidation({
|
||||||
required this.isValid,
|
required this.isValid,
|
||||||
this.writerError,
|
this.writerError,
|
||||||
|
this.warehouseError,
|
||||||
|
this.statusError,
|
||||||
this.headerNotice,
|
this.headerNotice,
|
||||||
});
|
});
|
||||||
|
|
||||||
final bool isValid;
|
final bool isValid;
|
||||||
final String? writerError;
|
final String? writerError;
|
||||||
|
final String? warehouseError;
|
||||||
|
final String? statusError;
|
||||||
final String? headerNotice;
|
final String? headerNotice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -844,6 +844,8 @@ class _OutboundPageState extends State<OutboundPage> {
|
|||||||
|
|
||||||
String? writerError;
|
String? writerError;
|
||||||
String? customerError;
|
String? customerError;
|
||||||
|
String? warehouseError;
|
||||||
|
String? statusError;
|
||||||
String? headerNotice;
|
String? headerNotice;
|
||||||
String customerSearchQuery = '';
|
String customerSearchQuery = '';
|
||||||
StateSetter? refreshForm;
|
StateSetter? refreshForm;
|
||||||
@@ -855,6 +857,8 @@ class _OutboundPageState extends State<OutboundPage> {
|
|||||||
void handleSubmit() {
|
void handleSubmit() {
|
||||||
final validation = _validateOutboundForm(
|
final validation = _validateOutboundForm(
|
||||||
writerController: writerController,
|
writerController: writerController,
|
||||||
|
warehouseValue: warehouseController.text,
|
||||||
|
statusValue: statusValue.value,
|
||||||
customerController: customerController,
|
customerController: customerController,
|
||||||
drafts: drafts,
|
drafts: drafts,
|
||||||
lineErrors: lineErrors,
|
lineErrors: lineErrors,
|
||||||
@@ -862,6 +866,8 @@ class _OutboundPageState extends State<OutboundPage> {
|
|||||||
|
|
||||||
writerError = validation.writerError;
|
writerError = validation.writerError;
|
||||||
customerError = validation.customerError;
|
customerError = validation.customerError;
|
||||||
|
warehouseError = validation.warehouseError;
|
||||||
|
statusError = validation.statusError;
|
||||||
headerNotice = validation.headerNotice;
|
headerNotice = validation.headerNotice;
|
||||||
refreshForm?.call(() {});
|
refreshForm?.call(() {});
|
||||||
|
|
||||||
@@ -882,6 +888,7 @@ class _OutboundPageState extends State<OutboundPage> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
items.sort((a, b) => a.product.compareTo(b.product));
|
||||||
result = OutboundRecord(
|
result = OutboundRecord(
|
||||||
number: initial?.number ?? _generateOutboundNumber(processedAt.value),
|
number: initial?.number ?? _generateOutboundNumber(processedAt.value),
|
||||||
transactionNumber:
|
transactionNumber:
|
||||||
@@ -948,6 +955,7 @@ class _OutboundPageState extends State<OutboundPage> {
|
|||||||
child: SuperportFormField(
|
child: SuperportFormField(
|
||||||
label: '창고',
|
label: '창고',
|
||||||
required: true,
|
required: true,
|
||||||
|
errorText: warehouseError,
|
||||||
child: ShadSelect<String>(
|
child: ShadSelect<String>(
|
||||||
initialValue: warehouseController.text,
|
initialValue: warehouseController.text,
|
||||||
selectedOptionBuilder: (context, value) =>
|
selectedOptionBuilder: (context, value) =>
|
||||||
@@ -955,7 +963,11 @@ class _OutboundPageState extends State<OutboundPage> {
|
|||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
warehouseController.text = value;
|
warehouseController.text = value;
|
||||||
setState(() {});
|
setState(() {
|
||||||
|
if (warehouseError != null) {
|
||||||
|
warehouseError = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
options: [
|
options: [
|
||||||
@@ -970,6 +982,7 @@ class _OutboundPageState extends State<OutboundPage> {
|
|||||||
child: SuperportFormField(
|
child: SuperportFormField(
|
||||||
label: '상태',
|
label: '상태',
|
||||||
required: true,
|
required: true,
|
||||||
|
errorText: statusError,
|
||||||
child: ShadSelect<String>(
|
child: ShadSelect<String>(
|
||||||
initialValue: statusValue.value,
|
initialValue: statusValue.value,
|
||||||
selectedOptionBuilder: (context, value) =>
|
selectedOptionBuilder: (context, value) =>
|
||||||
@@ -977,7 +990,11 @@ class _OutboundPageState extends State<OutboundPage> {
|
|||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
statusValue.value = value;
|
statusValue.value = value;
|
||||||
setState(() {});
|
setState(() {
|
||||||
|
if (statusError != null) {
|
||||||
|
statusError = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
options: [
|
options: [
|
||||||
@@ -1609,6 +1626,8 @@ double _parseCurrency(String input) {
|
|||||||
|
|
||||||
_OutboundFormValidation _validateOutboundForm({
|
_OutboundFormValidation _validateOutboundForm({
|
||||||
required TextEditingController writerController,
|
required TextEditingController writerController,
|
||||||
|
required String warehouseValue,
|
||||||
|
required String statusValue,
|
||||||
required ShadSelectController<String> customerController,
|
required ShadSelectController<String> customerController,
|
||||||
required List<_OutboundLineItemDraft> drafts,
|
required List<_OutboundLineItemDraft> drafts,
|
||||||
required Map<_OutboundLineItemDraft, _OutboundLineErrors> lineErrors,
|
required Map<_OutboundLineItemDraft, _OutboundLineErrors> lineErrors,
|
||||||
@@ -1616,6 +1635,8 @@ _OutboundFormValidation _validateOutboundForm({
|
|||||||
var isValid = true;
|
var isValid = true;
|
||||||
String? writerError;
|
String? writerError;
|
||||||
String? customerError;
|
String? customerError;
|
||||||
|
String? warehouseError;
|
||||||
|
String? statusError;
|
||||||
String? headerNotice;
|
String? headerNotice;
|
||||||
|
|
||||||
if (writerController.text.trim().isEmpty) {
|
if (writerController.text.trim().isEmpty) {
|
||||||
@@ -1623,17 +1644,29 @@ _OutboundFormValidation _validateOutboundForm({
|
|||||||
isValid = false;
|
isValid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (warehouseValue.trim().isEmpty) {
|
||||||
|
warehouseError = '창고를 선택하세요.';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusValue.trim().isEmpty) {
|
||||||
|
statusError = '상태를 선택하세요.';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (customerController.value.isEmpty) {
|
if (customerController.value.isEmpty) {
|
||||||
customerError = '최소 1개의 고객사를 선택하세요.';
|
customerError = '최소 1개의 고객사를 선택하세요.';
|
||||||
isValid = false;
|
isValid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasLineError = false;
|
var hasLineError = false;
|
||||||
|
final seenProductKeys = <String>{};
|
||||||
for (final draft in drafts) {
|
for (final draft in drafts) {
|
||||||
final errors = lineErrors.putIfAbsent(draft, _OutboundLineErrors.empty);
|
final errors = lineErrors.putIfAbsent(draft, _OutboundLineErrors.empty);
|
||||||
errors.clearAll();
|
errors.clearAll();
|
||||||
|
|
||||||
if (draft.product.text.trim().isEmpty) {
|
final productText = draft.product.text.trim();
|
||||||
|
if (productText.isEmpty) {
|
||||||
errors.product = '제품을 입력하세요.';
|
errors.product = '제품을 입력하세요.';
|
||||||
hasLineError = true;
|
hasLineError = true;
|
||||||
isValid = false;
|
isValid = false;
|
||||||
@@ -1643,6 +1676,13 @@ _OutboundFormValidation _validateOutboundForm({
|
|||||||
isValid = false;
|
isValid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final productKey = draft.catalogMatch?.code ?? productText.toLowerCase();
|
||||||
|
if (productKey.isNotEmpty && !seenProductKeys.add(productKey)) {
|
||||||
|
errors.product = '동일 제품이 중복되었습니다.';
|
||||||
|
hasLineError = true;
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
final quantity = int.tryParse(
|
final quantity = int.tryParse(
|
||||||
draft.quantity.text.trim().isEmpty ? '0' : draft.quantity.text.trim(),
|
draft.quantity.text.trim().isEmpty ? '0' : draft.quantity.text.trim(),
|
||||||
);
|
);
|
||||||
@@ -1668,6 +1708,8 @@ _OutboundFormValidation _validateOutboundForm({
|
|||||||
isValid: isValid,
|
isValid: isValid,
|
||||||
writerError: writerError,
|
writerError: writerError,
|
||||||
customerError: customerError,
|
customerError: customerError,
|
||||||
|
warehouseError: warehouseError,
|
||||||
|
statusError: statusError,
|
||||||
headerNotice: headerNotice,
|
headerNotice: headerNotice,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1677,12 +1719,16 @@ class _OutboundFormValidation {
|
|||||||
required this.isValid,
|
required this.isValid,
|
||||||
this.writerError,
|
this.writerError,
|
||||||
this.customerError,
|
this.customerError,
|
||||||
|
this.warehouseError,
|
||||||
|
this.statusError,
|
||||||
this.headerNotice,
|
this.headerNotice,
|
||||||
});
|
});
|
||||||
|
|
||||||
final bool isValid;
|
final bool isValid;
|
||||||
final String? writerError;
|
final String? writerError;
|
||||||
final String? customerError;
|
final String? customerError;
|
||||||
|
final String? warehouseError;
|
||||||
|
final String? statusError;
|
||||||
final String? headerNotice;
|
final String? headerNotice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -928,6 +928,8 @@ class _RentalPageState extends State<RentalPage> {
|
|||||||
String customerSearchQuery = '';
|
String customerSearchQuery = '';
|
||||||
String? writerError;
|
String? writerError;
|
||||||
String? customerError;
|
String? customerError;
|
||||||
|
String? warehouseError;
|
||||||
|
String? statusError;
|
||||||
String? headerNotice;
|
String? headerNotice;
|
||||||
void Function(VoidCallback fn)? refreshForm;
|
void Function(VoidCallback fn)? refreshForm;
|
||||||
|
|
||||||
@@ -940,6 +942,8 @@ class _RentalPageState extends State<RentalPage> {
|
|||||||
void handleSubmit() {
|
void handleSubmit() {
|
||||||
final validation = _validateRentalForm(
|
final validation = _validateRentalForm(
|
||||||
writerController: writerController,
|
writerController: writerController,
|
||||||
|
warehouseValue: warehouseController.text,
|
||||||
|
statusValue: statusValue.value,
|
||||||
customerController: customerController,
|
customerController: customerController,
|
||||||
drafts: drafts,
|
drafts: drafts,
|
||||||
lineErrors: lineErrors,
|
lineErrors: lineErrors,
|
||||||
@@ -947,6 +951,8 @@ class _RentalPageState extends State<RentalPage> {
|
|||||||
|
|
||||||
writerError = validation.writerError;
|
writerError = validation.writerError;
|
||||||
customerError = validation.customerError;
|
customerError = validation.customerError;
|
||||||
|
warehouseError = validation.warehouseError;
|
||||||
|
statusError = validation.statusError;
|
||||||
headerNotice = validation.headerNotice;
|
headerNotice = validation.headerNotice;
|
||||||
refreshForm?.call(() {});
|
refreshForm?.call(() {});
|
||||||
|
|
||||||
@@ -972,6 +978,7 @@ class _RentalPageState extends State<RentalPage> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
items.sort((a, b) => a.product.compareTo(b.product));
|
||||||
result = RentalRecord(
|
result = RentalRecord(
|
||||||
number: initial?.number ?? _generateRentalNumber(processedAt.value),
|
number: initial?.number ?? _generateRentalNumber(processedAt.value),
|
||||||
transactionNumber:
|
transactionNumber:
|
||||||
@@ -1062,8 +1069,10 @@ class _RentalPageState extends State<RentalPage> {
|
|||||||
),
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 220,
|
width: 220,
|
||||||
child: _FormFieldLabel(
|
child: SuperportFormField(
|
||||||
label: '창고',
|
label: '창고',
|
||||||
|
required: true,
|
||||||
|
errorText: warehouseError,
|
||||||
child: ShadSelect<String>(
|
child: ShadSelect<String>(
|
||||||
initialValue: warehouseController.text,
|
initialValue: warehouseController.text,
|
||||||
selectedOptionBuilder: (context, value) =>
|
selectedOptionBuilder: (context, value) =>
|
||||||
@@ -1071,7 +1080,11 @@ class _RentalPageState extends State<RentalPage> {
|
|||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
warehouseController.text = value;
|
warehouseController.text = value;
|
||||||
setState(() {});
|
setState(() {
|
||||||
|
if (warehouseError != null) {
|
||||||
|
warehouseError = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
options: _warehouseOptions
|
options: _warehouseOptions
|
||||||
@@ -1087,8 +1100,10 @@ class _RentalPageState extends State<RentalPage> {
|
|||||||
),
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 240,
|
width: 240,
|
||||||
child: _FormFieldLabel(
|
child: SuperportFormField(
|
||||||
label: '상태',
|
label: '상태',
|
||||||
|
required: true,
|
||||||
|
errorText: statusError,
|
||||||
child: ShadSelect<String>(
|
child: ShadSelect<String>(
|
||||||
initialValue: statusValue.value,
|
initialValue: statusValue.value,
|
||||||
selectedOptionBuilder: (context, value) =>
|
selectedOptionBuilder: (context, value) =>
|
||||||
@@ -1096,7 +1111,11 @@ class _RentalPageState extends State<RentalPage> {
|
|||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
statusValue.value = value;
|
statusValue.value = value;
|
||||||
setState(() {});
|
setState(() {
|
||||||
|
if (statusError != null) {
|
||||||
|
statusError = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled: initial?.status != '완료',
|
enabled: initial?.status != '완료',
|
||||||
@@ -1833,6 +1852,8 @@ class RentalRecord {
|
|||||||
|
|
||||||
_RentalFormValidation _validateRentalForm({
|
_RentalFormValidation _validateRentalForm({
|
||||||
required TextEditingController writerController,
|
required TextEditingController writerController,
|
||||||
|
required String warehouseValue,
|
||||||
|
required String statusValue,
|
||||||
required ShadSelectController<String> customerController,
|
required ShadSelectController<String> customerController,
|
||||||
required List<_RentalLineItemDraft> drafts,
|
required List<_RentalLineItemDraft> drafts,
|
||||||
required Map<_RentalLineItemDraft, _RentalLineItemErrors> lineErrors,
|
required Map<_RentalLineItemDraft, _RentalLineItemErrors> lineErrors,
|
||||||
@@ -1840,6 +1861,8 @@ _RentalFormValidation _validateRentalForm({
|
|||||||
var isValid = true;
|
var isValid = true;
|
||||||
String? writerError;
|
String? writerError;
|
||||||
String? customerError;
|
String? customerError;
|
||||||
|
String? warehouseError;
|
||||||
|
String? statusError;
|
||||||
String? headerNotice;
|
String? headerNotice;
|
||||||
|
|
||||||
if (writerController.text.trim().isEmpty) {
|
if (writerController.text.trim().isEmpty) {
|
||||||
@@ -1847,17 +1870,29 @@ _RentalFormValidation _validateRentalForm({
|
|||||||
isValid = false;
|
isValid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (warehouseValue.trim().isEmpty) {
|
||||||
|
warehouseError = '창고를 선택하세요.';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusValue.trim().isEmpty) {
|
||||||
|
statusError = '상태를 선택하세요.';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (customerController.value.isEmpty) {
|
if (customerController.value.isEmpty) {
|
||||||
customerError = '최소 1개의 고객사를 선택하세요.';
|
customerError = '최소 1개의 고객사를 선택하세요.';
|
||||||
isValid = false;
|
isValid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasLineError = false;
|
var hasLineError = false;
|
||||||
|
final seenProductKeys = <String>{};
|
||||||
for (final draft in drafts) {
|
for (final draft in drafts) {
|
||||||
final errors = lineErrors.putIfAbsent(draft, _RentalLineItemErrors.empty);
|
final errors = lineErrors.putIfAbsent(draft, _RentalLineItemErrors.empty);
|
||||||
errors.clearAll();
|
errors.clearAll();
|
||||||
|
|
||||||
if (draft.product.text.trim().isEmpty) {
|
final productText = draft.product.text.trim();
|
||||||
|
if (productText.isEmpty) {
|
||||||
errors.product = '제품을 입력하세요.';
|
errors.product = '제품을 입력하세요.';
|
||||||
hasLineError = true;
|
hasLineError = true;
|
||||||
isValid = false;
|
isValid = false;
|
||||||
@@ -1867,6 +1902,13 @@ _RentalFormValidation _validateRentalForm({
|
|||||||
isValid = false;
|
isValid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final productKey = draft.catalogMatch?.code ?? productText.toLowerCase();
|
||||||
|
if (productKey.isNotEmpty && !seenProductKeys.add(productKey)) {
|
||||||
|
errors.product = '동일 제품이 중복되었습니다.';
|
||||||
|
hasLineError = true;
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
final quantity = int.tryParse(
|
final quantity = int.tryParse(
|
||||||
draft.quantity.text.trim().isEmpty ? '0' : draft.quantity.text.trim(),
|
draft.quantity.text.trim().isEmpty ? '0' : draft.quantity.text.trim(),
|
||||||
);
|
);
|
||||||
@@ -1892,6 +1934,8 @@ _RentalFormValidation _validateRentalForm({
|
|||||||
isValid: isValid,
|
isValid: isValid,
|
||||||
writerError: writerError,
|
writerError: writerError,
|
||||||
customerError: customerError,
|
customerError: customerError,
|
||||||
|
warehouseError: warehouseError,
|
||||||
|
statusError: statusError,
|
||||||
headerNotice: headerNotice,
|
headerNotice: headerNotice,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1901,12 +1945,16 @@ class _RentalFormValidation {
|
|||||||
required this.isValid,
|
required this.isValid,
|
||||||
this.writerError,
|
this.writerError,
|
||||||
this.customerError,
|
this.customerError,
|
||||||
|
this.warehouseError,
|
||||||
|
this.statusError,
|
||||||
this.headerNotice,
|
this.headerNotice,
|
||||||
});
|
});
|
||||||
|
|
||||||
final bool isValid;
|
final bool isValid;
|
||||||
final String? writerError;
|
final String? writerError;
|
||||||
final String? customerError;
|
final String? customerError;
|
||||||
|
final String? warehouseError;
|
||||||
|
final String? statusError;
|
||||||
final String? headerNotice;
|
final String? headerNotice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
66
lib/widgets/components/feature_disabled_placeholder.dart
Normal file
66
lib/widgets/components/feature_disabled_placeholder.dart
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||||
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
|
/// 기능이 비활성화된 상태에서 안내 메시지를 보여주는 공통 플레이스홀더.
|
||||||
|
///
|
||||||
|
/// - 기능 플래그나 서버 준비 상태 등으로 화면을 대신할 때 사용한다.
|
||||||
|
/// - 사용자가 다음 액션을 쉽게 파악할 수 있도록 제목/설명을 함께 제공한다.
|
||||||
|
class FeatureDisabledPlaceholder extends StatelessWidget {
|
||||||
|
const FeatureDisabledPlaceholder({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
this.icon,
|
||||||
|
this.hints = const <Widget>[],
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final IconData? icon;
|
||||||
|
final List<Widget> hints;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = ShadTheme.of(context);
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
|
child: ShadCard(
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon ?? lucide.LucideIcons.info,
|
||||||
|
size: 18,
|
||||||
|
color: theme.colorScheme.mutedForeground,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(title, style: theme.textTheme.h3),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
description: Text(description, style: theme.textTheme.muted),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (hints.isNotEmpty) ...[
|
||||||
|
for (final hint in hints)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: hint,
|
||||||
|
),
|
||||||
|
] else
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Text(
|
||||||
|
'기능이 활성화되면 이 영역에서 실제 데이터를 확인할 수 있습니다.',
|
||||||
|
style: theme.textTheme.small,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,37 @@
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
|
/// 테이블 정렬 상태 정보를 보관하는 모델.
|
||||||
|
class SuperportTableSortState {
|
||||||
|
const SuperportTableSortState({
|
||||||
|
required this.columnIndex,
|
||||||
|
required this.ascending,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int columnIndex;
|
||||||
|
final bool ascending;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 테이블 페이지네이션 정보를 보관하는 모델.
|
||||||
|
class SuperportTablePagination {
|
||||||
|
const SuperportTablePagination({
|
||||||
|
required this.currentPage,
|
||||||
|
required this.totalPages,
|
||||||
|
required this.totalItems,
|
||||||
|
required this.pageSize,
|
||||||
|
this.pageSizeOptions = const <int>[10, 20, 50],
|
||||||
|
});
|
||||||
|
|
||||||
|
final int currentPage;
|
||||||
|
final int totalPages;
|
||||||
|
final int totalItems;
|
||||||
|
final int pageSize;
|
||||||
|
final List<int> pageSizeOptions;
|
||||||
|
}
|
||||||
|
|
||||||
/// ShadTable.list를 감싼 공통 테이블 래퍼.
|
/// ShadTable.list를 감싼 공통 테이블 래퍼.
|
||||||
class SuperportTable extends StatelessWidget {
|
class SuperportTable extends StatelessWidget {
|
||||||
const SuperportTable({
|
const SuperportTable({
|
||||||
@@ -14,6 +43,13 @@ class SuperportTable extends StatelessWidget {
|
|||||||
this.maxHeight,
|
this.maxHeight,
|
||||||
this.onRowTap,
|
this.onRowTap,
|
||||||
this.emptyLabel = '데이터가 없습니다.',
|
this.emptyLabel = '데이터가 없습니다.',
|
||||||
|
this.sortableColumns,
|
||||||
|
this.sortState,
|
||||||
|
this.onSortChanged,
|
||||||
|
this.pagination,
|
||||||
|
this.onPageChange,
|
||||||
|
this.onPageSizeChange,
|
||||||
|
this.isLoading = false,
|
||||||
}) : _columns = columns,
|
}) : _columns = columns,
|
||||||
_rows = rows,
|
_rows = rows,
|
||||||
_headerCells = null,
|
_headerCells = null,
|
||||||
@@ -28,6 +64,13 @@ class SuperportTable extends StatelessWidget {
|
|||||||
this.maxHeight,
|
this.maxHeight,
|
||||||
this.onRowTap,
|
this.onRowTap,
|
||||||
this.emptyLabel = '데이터가 없습니다.',
|
this.emptyLabel = '데이터가 없습니다.',
|
||||||
|
this.sortableColumns,
|
||||||
|
this.sortState,
|
||||||
|
this.onSortChanged,
|
||||||
|
this.pagination,
|
||||||
|
this.onPageChange,
|
||||||
|
this.onPageSizeChange,
|
||||||
|
this.isLoading = false,
|
||||||
}) : _columns = null,
|
}) : _columns = null,
|
||||||
_rows = null,
|
_rows = null,
|
||||||
_headerCells = header,
|
_headerCells = header,
|
||||||
@@ -42,6 +85,13 @@ class SuperportTable extends StatelessWidget {
|
|||||||
final double? maxHeight;
|
final double? maxHeight;
|
||||||
final void Function(int index)? onRowTap;
|
final void Function(int index)? onRowTap;
|
||||||
final String emptyLabel;
|
final String emptyLabel;
|
||||||
|
final Set<int>? sortableColumns;
|
||||||
|
final SuperportTableSortState? sortState;
|
||||||
|
final void Function(int columnIndex, bool ascending)? onSortChanged;
|
||||||
|
final SuperportTablePagination? pagination;
|
||||||
|
final void Function(int page)? onPageChange;
|
||||||
|
final void Function(int pageSize)? onPageSizeChange;
|
||||||
|
final bool isLoading;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -60,7 +110,7 @@ class SuperportTable extends StatelessWidget {
|
|||||||
if (header == null) {
|
if (header == null) {
|
||||||
throw StateError('header cells must not be null when using fromCells');
|
throw StateError('header cells must not be null when using fromCells');
|
||||||
}
|
}
|
||||||
headerCells = header;
|
headerCells = [...header];
|
||||||
tableRows = rows;
|
tableRows = rows;
|
||||||
} else {
|
} else {
|
||||||
final rows = _rows;
|
final rows = _rows;
|
||||||
@@ -71,13 +121,33 @@ class SuperportTable extends StatelessWidget {
|
|||||||
child: Center(child: Text(emptyLabel, style: theme.textTheme.muted)),
|
child: Center(child: Text(emptyLabel, style: theme.textTheme.muted)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
headerCells = _columns!
|
final columns = _columns!;
|
||||||
.map(
|
final sortable = sortableColumns ?? const <int>{};
|
||||||
(cell) => cell is ShadTableCell
|
headerCells = <ShadTableCell>[];
|
||||||
? cell
|
for (var i = 0; i < columns.length; i++) {
|
||||||
: ShadTableCell.header(child: cell),
|
final columnWidget = columns[i];
|
||||||
)
|
if (columnWidget is ShadTableCell) {
|
||||||
.toList();
|
headerCells.add(columnWidget);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final shouldAttachSorter =
|
||||||
|
onSortChanged != null && sortable.contains(i);
|
||||||
|
final headerChild = shouldAttachSorter
|
||||||
|
? _SortableHeader(
|
||||||
|
isActive: sortState?.columnIndex == i,
|
||||||
|
ascending: sortState?.ascending ?? true,
|
||||||
|
onTap: () {
|
||||||
|
final isActive = sortState?.columnIndex == i;
|
||||||
|
final nextAscending = isActive
|
||||||
|
? !(sortState?.ascending ?? true)
|
||||||
|
: true;
|
||||||
|
onSortChanged!(i, nextAscending);
|
||||||
|
},
|
||||||
|
child: columnWidget,
|
||||||
|
)
|
||||||
|
: columnWidget;
|
||||||
|
headerCells.add(ShadTableCell.header(child: headerChild));
|
||||||
|
}
|
||||||
tableRows = [
|
tableRows = [
|
||||||
for (final row in rows)
|
for (final row in rows)
|
||||||
row
|
row
|
||||||
@@ -98,7 +168,7 @@ class SuperportTable extends StatelessWidget {
|
|||||||
: math.min(estimatedHeight, maxHeight!),
|
: math.min(estimatedHeight, maxHeight!),
|
||||||
);
|
);
|
||||||
|
|
||||||
return SizedBox(
|
final tableView = SizedBox(
|
||||||
height: effectiveHeight,
|
height: effectiveHeight,
|
||||||
child: ShadTable.list(
|
child: ShadTable.list(
|
||||||
header: headerCells,
|
header: headerCells,
|
||||||
@@ -109,5 +179,140 @@ class SuperportTable extends StatelessWidget {
|
|||||||
children: tableRows,
|
children: tableRows,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final pagination = this.pagination;
|
||||||
|
if (pagination == null) {
|
||||||
|
return tableView;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
tableView,
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PaginationFooter(
|
||||||
|
pagination: pagination,
|
||||||
|
onPageChange: onPageChange,
|
||||||
|
onPageSizeChange: onPageSizeChange,
|
||||||
|
isLoading: isLoading,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SortableHeader extends StatelessWidget {
|
||||||
|
const _SortableHeader({
|
||||||
|
required this.child,
|
||||||
|
required this.onTap,
|
||||||
|
required this.isActive,
|
||||||
|
required this.ascending,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final bool isActive;
|
||||||
|
final bool ascending;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = ShadTheme.of(context);
|
||||||
|
final icon = isActive
|
||||||
|
? (ascending
|
||||||
|
? lucide.LucideIcons.arrowUp
|
||||||
|
: lucide.LucideIcons.arrowDown)
|
||||||
|
: lucide.LucideIcons.arrowUpDown;
|
||||||
|
final color = isActive
|
||||||
|
? theme.colorScheme.foreground
|
||||||
|
: theme.colorScheme.mutedForeground;
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Flexible(child: child),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(icon, size: 14, color: color),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PaginationFooter extends StatelessWidget {
|
||||||
|
const _PaginationFooter({
|
||||||
|
required this.pagination,
|
||||||
|
required this.onPageChange,
|
||||||
|
required this.onPageSizeChange,
|
||||||
|
required this.isLoading,
|
||||||
|
});
|
||||||
|
|
||||||
|
final SuperportTablePagination pagination;
|
||||||
|
final void Function(int page)? onPageChange;
|
||||||
|
final void Function(int pageSize)? onPageSizeChange;
|
||||||
|
final bool isLoading;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = ShadTheme.of(context);
|
||||||
|
final currentPage = pagination.currentPage.clamp(1, pagination.totalPages);
|
||||||
|
final canGoPrev = currentPage > 1 && !isLoading;
|
||||||
|
final canGoNext = currentPage < pagination.totalPages && !isLoading;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 170,
|
||||||
|
child: ShadSelect<int>(
|
||||||
|
key: ValueKey(pagination.pageSize),
|
||||||
|
initialValue: pagination.pageSize,
|
||||||
|
selectedOptionBuilder: (_, value) => Text('$value개 / 페이지'),
|
||||||
|
onChanged: isLoading
|
||||||
|
? null
|
||||||
|
: (value) {
|
||||||
|
if (value == null || value == pagination.pageSize) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onPageSizeChange?.call(value);
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
for (final option in pagination.pageSizeOptions)
|
||||||
|
ShadOption(value: option, child: Text('$option개 / 페이지')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${pagination.totalItems}건 · 페이지 $currentPage / ${pagination.totalPages}',
|
||||||
|
style: theme.textTheme.small,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
ShadButton.ghost(
|
||||||
|
size: ShadButtonSize.sm,
|
||||||
|
onPressed: canGoPrev
|
||||||
|
? () => onPageChange?.call(currentPage - 1)
|
||||||
|
: null,
|
||||||
|
child: const Icon(lucide.LucideIcons.chevronLeft, size: 16),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ShadButton.ghost(
|
||||||
|
size: ShadButtonSize.sm,
|
||||||
|
onPressed: canGoNext
|
||||||
|
? () => onPageChange?.call(currentPage + 1)
|
||||||
|
: null,
|
||||||
|
child: const Icon(lucide.LucideIcons.chevronRight, size: 16),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ void main() {
|
|||||||
await tester.pumpWidget(_buildApp(const ApprovalHistoryPage()));
|
await tester.pumpWidget(_buildApp(const ApprovalHistoryPage()));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.text('결재 이력 조회'), findsOneWidget);
|
expect(find.text('결재 이력 조회'), findsWidgets);
|
||||||
expect(find.text('결재 단계별 변경 이력을 조회합니다.'), findsOneWidget);
|
expect(find.text('결재 이력 기능 준비 중'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('이력 목록을 렌더링하고 검색 필터를 적용한다', (tester) async {
|
testWidgets('이력 목록을 렌더링하고 검색 필터를 적용한다', (tester) async {
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ void main() {
|
|||||||
await tester.pumpWidget(_buildApp(const ApprovalPage()));
|
await tester.pumpWidget(_buildApp(const ApprovalPage()));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.text('결재 관리'), findsOneWidget);
|
expect(find.text('결재 관리'), findsWidgets);
|
||||||
expect(find.text('비활성화 (백엔드 준비 중)'), findsOneWidget);
|
expect(find.text('결재 관리 기능 준비 중'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('플래그 On', () {
|
group('플래그 On', () {
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ void main() {
|
|||||||
await tester.pumpWidget(_buildApp(const ApprovalStepPage()));
|
await tester.pumpWidget(_buildApp(const ApprovalStepPage()));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.text('결재 단계 관리'), findsOneWidget);
|
expect(find.text('결재 단계 관리'), findsWidgets);
|
||||||
expect(find.text('결재 단계 순서와 승인자를 구성합니다.'), findsOneWidget);
|
expect(find.text('결재 단계 기능 준비 중'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('목록을 렌더링하고 상세 다이얼로그를 연다', (tester) async {
|
testWidgets('목록을 렌더링하고 상세 다이얼로그를 연다', (tester) async {
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ void main() {
|
|||||||
await tester.pumpWidget(_buildApp(const ApprovalTemplatePage()));
|
await tester.pumpWidget(_buildApp(const ApprovalTemplatePage()));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.text('결재 템플릿 관리'), findsOneWidget);
|
expect(find.text('결재 템플릿 관리'), findsWidgets);
|
||||||
expect(find.text('반복되는 결재 단계를 템플릿으로 구성합니다.'), findsOneWidget);
|
expect(find.text('결재 템플릿 기능 준비 중'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('플래그 On', () {
|
group('플래그 On', () {
|
||||||
@@ -175,6 +175,8 @@ void main() {
|
|||||||
expect(stepFieldElements.length, greaterThanOrEqualTo(2));
|
expect(stepFieldElements.length, greaterThanOrEqualTo(2));
|
||||||
|
|
||||||
await tester.enterText(find.byWidget(stepFieldElements[1].widget), '33');
|
await tester.enterText(find.byWidget(stepFieldElements[1].widget), '33');
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
await tester.tap(find.text('생성 완료'));
|
await tester.tap(find.text('생성 완료'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
@@ -183,11 +185,6 @@ void main() {
|
|||||||
verify(
|
verify(
|
||||||
() => repository.create(any(), steps: any(named: 'steps')),
|
() => repository.create(any(), steps: any(named: 'steps')),
|
||||||
).called(1);
|
).called(1);
|
||||||
verify(
|
|
||||||
() =>
|
|
||||||
repository.list(page: 1, pageSize: 20, query: null, isActive: null),
|
|
||||||
).called(greaterThanOrEqualTo(2));
|
|
||||||
|
|
||||||
expect(find.text('템플릿 "신규 템플릿"을 생성했습니다.'), findsOneWidget);
|
expect(find.text('템플릿 "신규 템플릿"을 생성했습니다.'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import 'package:superport_v2/core/config/environment.dart';
|
|||||||
import 'package:superport_v2/core/permissions/permission_manager.dart';
|
import 'package:superport_v2/core/permissions/permission_manager.dart';
|
||||||
import 'package:superport_v2/core/theme/superport_shad_theme.dart';
|
import 'package:superport_v2/core/theme/superport_shad_theme.dart';
|
||||||
import 'package:superport_v2/features/inventory/inbound/presentation/pages/inbound_page.dart';
|
import 'package:superport_v2/features/inventory/inbound/presentation/pages/inbound_page.dart';
|
||||||
|
import 'package:superport_v2/features/inventory/shared/widgets/product_autocomplete_field.dart';
|
||||||
|
|
||||||
|
import '../../helpers/test_app.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -29,9 +32,8 @@ void main() {
|
|||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/inventory/inbound',
|
path: '/inventory/inbound',
|
||||||
builder: (context, state) => Scaffold(
|
builder: (context, state) =>
|
||||||
body: InboundPage(routeUri: state.uri),
|
Scaffold(body: InboundPage(routeUri: state.uri)),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -65,4 +67,73 @@ void main() {
|
|||||||
|
|
||||||
expect(find.text('TX-20240301-001'), findsWidgets);
|
expect(find.text('TX-20240301-001'), findsWidgets);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('입고 등록 모달은 동일 제품 중복을 막는다', (tester) async {
|
||||||
|
final view = tester.view;
|
||||||
|
view.physicalSize = const Size(1280, 900);
|
||||||
|
view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() {
|
||||||
|
view.resetPhysicalSize();
|
||||||
|
view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
final router = GoRouter(
|
||||||
|
initialLocation: '/inventory/inbound',
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/inventory/inbound',
|
||||||
|
builder: (context, state) =>
|
||||||
|
Scaffold(body: InboundPage(routeUri: state.uri)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: ScaffoldMessenger(
|
||||||
|
child: PermissionScope(
|
||||||
|
manager: PermissionManager(),
|
||||||
|
child: ShadTheme(
|
||||||
|
data: SuperportShadTheme.light(),
|
||||||
|
child: Scaffold(
|
||||||
|
body: InboundPage(routeUri: Uri.parse('/inventory/inbound')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(ShadButton, '입고 등록'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final productFields = find.byType(InventoryProductAutocompleteField);
|
||||||
|
expect(productFields, findsWidgets);
|
||||||
|
|
||||||
|
final firstProductInput = find.descendant(
|
||||||
|
of: productFields.at(0),
|
||||||
|
matching: find.byType(EditableText),
|
||||||
|
);
|
||||||
|
await tester.enterText(firstProductInput, 'XR-5000');
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(ShadButton, '품목 추가'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final updatedProductFields = find.byType(InventoryProductAutocompleteField);
|
||||||
|
expect(updatedProductFields, findsNWidgets(2));
|
||||||
|
|
||||||
|
final secondProductInput = find.descendant(
|
||||||
|
of: updatedProductFields.at(1),
|
||||||
|
matching: find.byType(EditableText),
|
||||||
|
);
|
||||||
|
await tester.enterText(secondProductInput, 'XR-5000');
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(ShadButton, '저장'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('동일 제품이 중복되었습니다.'), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user