결재 비활성 안내 개선 및 테이블 기능 보강
This commit is contained in:
@@ -19,6 +19,7 @@ class ApprovalHistoryController extends ChangeNotifier {
|
||||
DateTime? _from;
|
||||
DateTime? _to;
|
||||
String? _errorMessage;
|
||||
int _pageSize = 20;
|
||||
|
||||
PaginatedResult<ApprovalHistoryRecord>? get result => _result;
|
||||
bool get isLoading => _isLoading;
|
||||
@@ -27,6 +28,7 @@ class ApprovalHistoryController extends ChangeNotifier {
|
||||
DateTime? get from => _from;
|
||||
DateTime? get to => _to;
|
||||
String? get errorMessage => _errorMessage;
|
||||
int get pageSize => _result?.pageSize ?? _pageSize;
|
||||
|
||||
Future<void> fetch({int page = 1}) async {
|
||||
_isLoading = true;
|
||||
@@ -42,13 +44,14 @@ class ApprovalHistoryController extends ChangeNotifier {
|
||||
|
||||
final response = await _repository.list(
|
||||
page: page,
|
||||
pageSize: _result?.pageSize ?? 20,
|
||||
pageSize: _pageSize,
|
||||
query: _query.trim().isEmpty ? null : _query.trim(),
|
||||
action: action,
|
||||
from: _from,
|
||||
to: _to,
|
||||
);
|
||||
_result = response;
|
||||
_pageSize = response.pageSize;
|
||||
} catch (e) {
|
||||
_errorMessage = e.toString();
|
||||
} finally {
|
||||
@@ -86,6 +89,14 @@ class ApprovalHistoryController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updatePageSize(int value) {
|
||||
if (value <= 0) {
|
||||
return;
|
||||
}
|
||||
_pageSize = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool get hasActiveFilters =>
|
||||
_query.trim().isNotEmpty ||
|
||||
_actionFilter != ApprovalHistoryActionFilter.all ||
|
||||
|
||||
@@ -10,7 +10,7 @@ import '../../../../../widgets/app_layout.dart';
|
||||
import '../../../../../widgets/components/filter_bar.dart';
|
||||
import '../../../../../widgets/components/superport_date_picker.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/repositories/approval_history_repository.dart';
|
||||
import '../controllers/approval_history_controller.dart';
|
||||
@@ -22,41 +22,28 @@ class ApprovalHistoryPage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = Environment.flag('FEATURE_APPROVALS_ENABLED');
|
||||
if (!enabled) {
|
||||
return const SpecPage(
|
||||
return AppLayout(
|
||||
title: '결재 이력 조회',
|
||||
summary: '결재 단계별 변경 이력을 조회합니다.',
|
||||
sections: [
|
||||
SpecSection(
|
||||
title: '조회 테이블',
|
||||
description: '수정 없이 이력 리스트만 제공.',
|
||||
table: SpecTable(
|
||||
columns: [
|
||||
'번호',
|
||||
'결재ID',
|
||||
'단계순서',
|
||||
'승인자',
|
||||
'행위',
|
||||
'변경전상태',
|
||||
'변경후상태',
|
||||
'작업일시',
|
||||
'비고',
|
||||
],
|
||||
rows: [
|
||||
[
|
||||
'1',
|
||||
'APP-20240301-001',
|
||||
'1',
|
||||
'최관리',
|
||||
'승인',
|
||||
'승인대기',
|
||||
'승인완료',
|
||||
'2024-03-01 10:30',
|
||||
'-',
|
||||
],
|
||||
],
|
||||
subtitle: '결재 단계별 변경 내역을 기간·행위·결재번호 기준으로 확인합니다.',
|
||||
breadcrumbs: const [
|
||||
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
|
||||
AppBreadcrumbItem(label: '결재', path: '/approvals/history'),
|
||||
AppBreadcrumbItem(label: '결재 이력'),
|
||||
],
|
||||
actions: [
|
||||
Tooltip(
|
||||
message: '다운로드는 API 연동 이후 제공됩니다.',
|
||||
child: ShadButton(
|
||||
onPressed: null,
|
||||
leading: const Icon(lucide.LucideIcons.download, size: 16),
|
||||
child: const Text('엑셀 다운로드'),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: const FeatureDisabledPlaceholder(
|
||||
title: '결재 이력 기능 준비 중',
|
||||
description: '결재 이력 API가 공개되면 검색과 엑셀 다운로드 기능이 활성화됩니다.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,6 +67,10 @@ class _ApprovalHistoryEnabledPageState
|
||||
final DateFormat _dateTimeFormat = DateFormat('yyyy-MM-dd HH:mm');
|
||||
DateTimeRange? _dateRange;
|
||||
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
|
||||
void initState() {
|
||||
@@ -125,9 +116,7 @@ class _ApprovalHistoryEnabledPageState
|
||||
final totalPages = result == null || result.pageSize == 0
|
||||
? 1
|
||||
: (result.total / result.pageSize).ceil().clamp(1, 9999);
|
||||
final hasNext = result == null
|
||||
? false
|
||||
: (result.page * result.pageSize) < result.total;
|
||||
final sortedHistories = _applySorting(histories);
|
||||
|
||||
return AppLayout(
|
||||
title: '결재 이력 조회',
|
||||
@@ -231,34 +220,6 @@ class _ApprovalHistoryEnabledPageState
|
||||
Text('$totalCount건', style: theme.textTheme.muted),
|
||||
],
|
||||
),
|
||||
footer: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'페이지 $currentPage / $totalPages',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isLoading || currentPage <= 1
|
||||
? null
|
||||
: () => _controller.fetch(page: currentPage - 1),
|
||||
child: const Text('이전'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isLoading || !hasNext
|
||||
? null
|
||||
: () => _controller.fetch(page: currentPage + 1),
|
||||
child: const Text('다음'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
child: _controller.isLoading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(48),
|
||||
@@ -273,9 +234,30 @@ class _ApprovalHistoryEnabledPageState
|
||||
),
|
||||
)
|
||||
: _ApprovalHistoryTable(
|
||||
histories: histories,
|
||||
histories: sortedHistories,
|
||||
dateFormat: _dateTimeFormat,
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
switch (filter) {
|
||||
case ApprovalHistoryActionFilter.all:
|
||||
@@ -324,11 +358,25 @@ class _ApprovalHistoryTable extends StatelessWidget {
|
||||
required this.histories,
|
||||
required this.dateFormat,
|
||||
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 DateFormat dateFormat;
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
@@ -402,6 +450,13 @@ class _ApprovalHistoryTable extends StatelessWidget {
|
||||
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_dialog.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_template.dart';
|
||||
import '../../domain/repositories/approval_repository.dart';
|
||||
@@ -29,69 +29,28 @@ class ApprovalPage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = Environment.flag('FEATURE_APPROVALS_ENABLED');
|
||||
if (!enabled) {
|
||||
return SpecPage(
|
||||
return AppLayout(
|
||||
title: '결재 관리',
|
||||
summary: '결재 요청 상태와 단계/이력을 모니터링합니다.',
|
||||
trailing: ShadBadge(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
Icon(lucide.LucideIcons.info, size: 14),
|
||||
SizedBox(width: 6),
|
||||
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',
|
||||
'-',
|
||||
'-',
|
||||
],
|
||||
],
|
||||
subtitle: '결재 요청 상태와 단계/이력을 한 화면에서 확인합니다.',
|
||||
breadcrumbs: const [
|
||||
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
|
||||
AppBreadcrumbItem(label: '결재', path: '/approvals/requests'),
|
||||
AppBreadcrumbItem(label: '결재 관리'),
|
||||
],
|
||||
actions: [
|
||||
Tooltip(
|
||||
message: '백엔드 연동 후 사용 가능합니다.',
|
||||
child: ShadButton(
|
||||
onPressed: null,
|
||||
leading: const Icon(lucide.LucideIcons.plus, size: 16),
|
||||
child: const Text('신규 결재'),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: const FeatureDisabledPlaceholder(
|
||||
title: '결재 관리 기능 준비 중',
|
||||
description: '결재 API 연결이 완료되면 실제 결재 요청 목록과 단계 정보를 제공합니다.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -181,6 +140,10 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
_approvalsResourcePath,
|
||||
PermissionAction.approve,
|
||||
);
|
||||
final canManageTemplates = permissionManager.can(
|
||||
_approvalsResourcePath,
|
||||
PermissionAction.edit,
|
||||
);
|
||||
|
||||
if (templates.isNotEmpty && _selectedTemplateId == null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -367,6 +330,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
applyingTemplateId: applyingTemplateId,
|
||||
selectedTemplateId: _selectedTemplateId,
|
||||
canPerformStepActions: canPerformStepActions,
|
||||
canApplyTemplate: canManageTemplates,
|
||||
dateFormat: _dateTimeFormat,
|
||||
onRefresh: () {
|
||||
final id = selectedApproval?.id;
|
||||
@@ -745,6 +709,7 @@ class _DetailSection extends StatelessWidget {
|
||||
required this.applyingTemplateId,
|
||||
required this.selectedTemplateId,
|
||||
required this.canPerformStepActions,
|
||||
required this.canApplyTemplate,
|
||||
required this.dateFormat,
|
||||
required this.onRefresh,
|
||||
required this.onClose,
|
||||
@@ -766,6 +731,7 @@ class _DetailSection extends StatelessWidget {
|
||||
final int? applyingTemplateId;
|
||||
final int? selectedTemplateId;
|
||||
final bool canPerformStepActions;
|
||||
final bool canApplyTemplate;
|
||||
final intl.DateFormat dateFormat;
|
||||
final VoidCallback onRefresh;
|
||||
final VoidCallback? onClose;
|
||||
@@ -845,6 +811,7 @@ class _DetailSection extends StatelessWidget {
|
||||
applyingTemplateId: applyingTemplateId,
|
||||
selectedTemplateId: selectedTemplateId,
|
||||
canPerformStepActions: canPerformStepActions,
|
||||
canApplyTemplate: canApplyTemplate,
|
||||
onSelectTemplate: onSelectTemplate,
|
||||
onApplyTemplate: onApplyTemplate,
|
||||
onReloadTemplates: onReloadTemplates,
|
||||
@@ -942,6 +909,7 @@ class _StepTab extends StatelessWidget {
|
||||
required this.applyingTemplateId,
|
||||
required this.selectedTemplateId,
|
||||
required this.canPerformStepActions,
|
||||
required this.canApplyTemplate,
|
||||
required this.onSelectTemplate,
|
||||
required this.onApplyTemplate,
|
||||
required this.onReloadTemplates,
|
||||
@@ -961,6 +929,7 @@ class _StepTab extends StatelessWidget {
|
||||
final int? applyingTemplateId;
|
||||
final int? selectedTemplateId;
|
||||
final bool canPerformStepActions;
|
||||
final bool canApplyTemplate;
|
||||
final void Function(int?) onSelectTemplate;
|
||||
final void Function(int templateId) onApplyTemplate;
|
||||
final VoidCallback onReloadTemplates;
|
||||
@@ -980,12 +949,20 @@ class _StepTab extends StatelessWidget {
|
||||
selectedTemplateId: selectedTemplateId,
|
||||
isApplyingTemplate: isApplyingTemplate,
|
||||
applyingTemplateId: applyingTemplateId,
|
||||
canApplyTemplate: canPerformStepActions,
|
||||
canApplyTemplate: canApplyTemplate,
|
||||
onSelectTemplate: onSelectTemplate,
|
||||
onApplyTemplate: onApplyTemplate,
|
||||
onReload: onReloadTemplates,
|
||||
),
|
||||
),
|
||||
if (!canApplyTemplate)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Text(
|
||||
'템플릿 적용 권한이 없어 단계 구성을 변경할 수 없습니다.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
),
|
||||
if (!isLoadingTemplates && templates.isEmpty)
|
||||
Padding(
|
||||
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/components/filter_bar.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 '../../domain/entities/approval_step_input.dart';
|
||||
import '../../domain/entities/approval_step_record.dart';
|
||||
@@ -21,53 +21,28 @@ class ApprovalStepPage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = Environment.flag('FEATURE_APPROVALS_ENABLED');
|
||||
if (!enabled) {
|
||||
return const SpecPage(
|
||||
return AppLayout(
|
||||
title: '결재 단계 관리',
|
||||
summary: '결재 단계 순서와 승인자를 구성합니다.',
|
||||
sections: [
|
||||
SpecSection(
|
||||
title: '입력 폼',
|
||||
items: [
|
||||
'결재ID [Dropdown]',
|
||||
'단계순서 [Number]',
|
||||
'승인자 [Dropdown]',
|
||||
'단계상태 [Dropdown]',
|
||||
'비고 [Text]',
|
||||
],
|
||||
),
|
||||
SpecSection(
|
||||
title: '수정 폼',
|
||||
items: ['결재ID [ReadOnly]', '단계순서 [ReadOnly]'],
|
||||
),
|
||||
SpecSection(
|
||||
title: '테이블 리스트',
|
||||
description: '1행 예시',
|
||||
table: SpecTable(
|
||||
columns: [
|
||||
'번호',
|
||||
'결재ID',
|
||||
'단계순서',
|
||||
'승인자',
|
||||
'상태',
|
||||
'배정일시',
|
||||
'결정일시',
|
||||
'비고',
|
||||
],
|
||||
rows: [
|
||||
[
|
||||
'1',
|
||||
'APP-20240301-001',
|
||||
'1',
|
||||
'최관리',
|
||||
'승인대기',
|
||||
'2024-03-01 09:00',
|
||||
'-',
|
||||
'-',
|
||||
],
|
||||
],
|
||||
subtitle: '결재 요청별 단계 현황을 구성하고 승인자를 배정합니다.',
|
||||
breadcrumbs: const [
|
||||
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
|
||||
AppBreadcrumbItem(label: '결재', path: '/approvals/steps'),
|
||||
AppBreadcrumbItem(label: '결재 단계'),
|
||||
],
|
||||
actions: [
|
||||
Tooltip(
|
||||
message: 'API 연동 이후 단계 추가가 가능합니다.',
|
||||
child: ShadButton(
|
||||
onPressed: null,
|
||||
leading: const Icon(lucide.LucideIcons.plus, size: 16),
|
||||
child: const Text('단계 추가'),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: const FeatureDisabledPlaceholder(
|
||||
title: '결재 단계 기능 준비 중',
|
||||
description: '결재 단계 API 연동을 기다리는 중입니다. 연동 후 단계 생성/수정/삭제 기능이 활성화됩니다.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ class ApprovalTemplateController extends ChangeNotifier {
|
||||
String _query = '';
|
||||
ApprovalTemplateStatusFilter _statusFilter = ApprovalTemplateStatusFilter.all;
|
||||
String? _errorMessage;
|
||||
int _pageSize = 20;
|
||||
|
||||
PaginatedResult<ApprovalTemplate>? get result => _result;
|
||||
bool get isLoading => _isLoading;
|
||||
@@ -28,6 +29,7 @@ class ApprovalTemplateController extends ChangeNotifier {
|
||||
String get query => _query;
|
||||
ApprovalTemplateStatusFilter get statusFilter => _statusFilter;
|
||||
String? get errorMessage => _errorMessage;
|
||||
int get pageSize => _result?.pageSize ?? _pageSize;
|
||||
|
||||
Future<void> fetch({int page = 1}) async {
|
||||
_isLoading = true;
|
||||
@@ -42,11 +44,12 @@ class ApprovalTemplateController extends ChangeNotifier {
|
||||
};
|
||||
final response = await _repository.list(
|
||||
page: page,
|
||||
pageSize: _result?.pageSize ?? 20,
|
||||
pageSize: _pageSize,
|
||||
query: sanitizedQuery.isEmpty ? null : sanitizedQuery,
|
||||
isActive: isActive,
|
||||
);
|
||||
_result = response;
|
||||
_pageSize = response.pageSize;
|
||||
} catch (e) {
|
||||
_errorMessage = e.toString();
|
||||
} finally {
|
||||
@@ -160,6 +163,14 @@ class ApprovalTemplateController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updatePageSize(int value) {
|
||||
if (value <= 0) {
|
||||
return;
|
||||
}
|
||||
_pageSize = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _setSubmitting(bool value) {
|
||||
_isSubmitting = value;
|
||||
notifyListeners();
|
||||
|
||||
@@ -9,7 +9,8 @@ import '../../../../../widgets/app_layout.dart';
|
||||
import '../../../../../widgets/components/filter_bar.dart';
|
||||
import '../../../../../widgets/components/superport_table.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/repositories/approval_template_repository.dart';
|
||||
import '../controllers/approval_template_controller.dart';
|
||||
@@ -21,43 +22,28 @@ class ApprovalTemplatePage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = Environment.flag('FEATURE_APPROVALS_ENABLED');
|
||||
if (!enabled) {
|
||||
return const SpecPage(
|
||||
return AppLayout(
|
||||
title: '결재 템플릿 관리',
|
||||
summary: '반복되는 결재 단계를 템플릿으로 구성합니다.',
|
||||
sections: [
|
||||
SpecSection(
|
||||
title: '입력 폼',
|
||||
items: [
|
||||
'템플릿코드 [Text]',
|
||||
'템플릿명 [Text]',
|
||||
'설명 [Text]',
|
||||
'사용여부 [Switch]',
|
||||
'비고 [Textarea]',
|
||||
'단계 추가: 순서 [Number], 승인자ID [Number]',
|
||||
],
|
||||
),
|
||||
SpecSection(
|
||||
title: '수정 폼',
|
||||
items: ['템플릿코드 [ReadOnly]', '작성자 [ReadOnly]'],
|
||||
),
|
||||
SpecSection(
|
||||
title: '테이블 리스트',
|
||||
description: '1행 예시',
|
||||
table: SpecTable(
|
||||
columns: ['번호', '템플릿코드', '템플릿명', '설명', '사용여부', '변경일시'],
|
||||
rows: [
|
||||
[
|
||||
'1',
|
||||
'AP_INBOUND',
|
||||
'입고 결재 기본',
|
||||
'입고 2단계',
|
||||
'Y',
|
||||
'2024-04-01 09:00',
|
||||
],
|
||||
],
|
||||
subtitle: '반복되는 결재 단계를 템플릿으로 저장하고 재사용합니다.',
|
||||
breadcrumbs: const [
|
||||
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
|
||||
AppBreadcrumbItem(label: '결재', path: '/approvals/templates'),
|
||||
AppBreadcrumbItem(label: '결재 템플릿'),
|
||||
],
|
||||
actions: [
|
||||
Tooltip(
|
||||
message: '템플릿 생성은 서버 연동 후 지원됩니다.',
|
||||
child: ShadButton(
|
||||
onPressed: null,
|
||||
leading: const Icon(lucide.LucideIcons.plus, size: 16),
|
||||
child: const Text('템플릿 생성'),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: const FeatureDisabledPlaceholder(
|
||||
title: '결재 템플릿 기능 준비 중',
|
||||
description: '백엔드 템플릿 API가 연동되면 템플릿 생성과 단계 구성 기능이 활성화됩니다.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,6 +66,7 @@ class _ApprovalTemplateEnabledPageState
|
||||
final FocusNode _searchFocus = FocusNode();
|
||||
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm');
|
||||
String? _lastError;
|
||||
static const _pageSizeOptions = [10, 20, 50];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -126,10 +113,6 @@ class _ApprovalTemplateEnabledPageState
|
||||
final totalPages = result == null || result.pageSize == 0
|
||||
? 1
|
||||
: (result.total / result.pageSize).ceil().clamp(1, 9999);
|
||||
final hasNext = result == null
|
||||
? false
|
||||
: (result.page * result.pageSize) < result.total;
|
||||
|
||||
final showReset =
|
||||
_searchController.text.trim().isNotEmpty ||
|
||||
_controller.statusFilter != ApprovalTemplateStatusFilter.all;
|
||||
@@ -305,42 +288,19 @@ class _ApprovalTemplateEnabledPageState
|
||||
return const FixedTableSpanExtent(140);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('총 $totalCount건', 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('다음'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'페이지 $currentPage / $totalPages',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -500,11 +460,7 @@ class _ApprovalTemplateEnabledPageState
|
||||
modalSetState?.call(() => isSaving = true);
|
||||
|
||||
final success = isEdit && existingTemplate != null
|
||||
? await _controller.update(
|
||||
existingTemplate.id,
|
||||
input,
|
||||
stepInputs,
|
||||
)
|
||||
? await _controller.update(existingTemplate.id, input, stepInputs)
|
||||
: await _controller.create(input, stepInputs);
|
||||
if (success != null && mounted) {
|
||||
Navigator.of(context).pop(true);
|
||||
@@ -517,7 +473,7 @@ class _ApprovalTemplateEnabledPageState
|
||||
context: context,
|
||||
title: isEdit ? '템플릿 수정' : '템플릿 생성',
|
||||
barrierDismissible: !isSaving,
|
||||
onSubmit: handleSubmit,
|
||||
onSubmit: handleSubmit,
|
||||
body: StatefulBuilder(
|
||||
builder: (dialogContext, setModalState) {
|
||||
modalSetState = setModalState;
|
||||
@@ -593,6 +549,7 @@ class _ApprovalTemplateEnabledPageState
|
||||
field: steps[index],
|
||||
index: index,
|
||||
isEdit: isEdit,
|
||||
isDisabled: isSaving,
|
||||
onRemove: steps.length <= 1 || isSaving
|
||||
? null
|
||||
: () {
|
||||
@@ -763,12 +720,14 @@ class _StepEditorRow extends StatelessWidget {
|
||||
required this.field,
|
||||
required this.index,
|
||||
required this.isEdit,
|
||||
required this.isDisabled,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
final _TemplateStepField field;
|
||||
final int index;
|
||||
final bool isEdit;
|
||||
final bool isDisabled;
|
||||
final VoidCallback? onRemove;
|
||||
|
||||
@override
|
||||
@@ -793,14 +752,18 @@ class _StepEditorRow extends StatelessWidget {
|
||||
controller: field.orderController,
|
||||
keyboardType: TextInputType.number,
|
||||
placeholder: const Text('단계 순서'),
|
||||
enabled: !isDisabled,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ShadInput(
|
||||
controller: field.approverController,
|
||||
keyboardType: TextInputType.number,
|
||||
placeholder: const Text('승인자 ID'),
|
||||
child: IgnorePointer(
|
||||
ignoring: isDisabled,
|
||||
child: ApprovalApproverAutocompleteField(
|
||||
idController: field.approverController,
|
||||
hintText: '승인자 검색',
|
||||
onSelected: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -813,11 +776,14 @@ class _StepEditorRow extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ShadTextarea(
|
||||
controller: field.noteController,
|
||||
minHeight: 60,
|
||||
maxHeight: 160,
|
||||
placeholder: const Text('비고 (선택)'),
|
||||
IgnorePointer(
|
||||
ignoring: isDisabled,
|
||||
child: ShadTextarea(
|
||||
controller: field.noteController,
|
||||
minHeight: 60,
|
||||
maxHeight: 160,
|
||||
placeholder: const Text('비고 (선택)'),
|
||||
),
|
||||
),
|
||||
if (isEdit && field.id != null)
|
||||
Padding(
|
||||
|
||||
@@ -837,6 +837,8 @@ class _InboundPageState extends State<InboundPage> {
|
||||
};
|
||||
|
||||
String? writerError;
|
||||
String? warehouseError;
|
||||
String? statusError;
|
||||
String? headerNotice;
|
||||
void Function(VoidCallback fn)? refreshForm;
|
||||
|
||||
@@ -847,10 +849,14 @@ class _InboundPageState extends State<InboundPage> {
|
||||
void handleSubmit() {
|
||||
final validationResult = _validateInboundForm(
|
||||
writerController: writerController,
|
||||
warehouseValue: warehouseController.text,
|
||||
statusValue: statusValue.value,
|
||||
drafts: drafts,
|
||||
lineErrors: lineErrors,
|
||||
);
|
||||
writerError = validationResult.writerError;
|
||||
warehouseError = validationResult.warehouseError;
|
||||
statusError = validationResult.statusError;
|
||||
headerNotice = validationResult.headerNotice;
|
||||
refreshForm?.call(() {});
|
||||
|
||||
@@ -871,6 +877,7 @@ class _InboundPageState extends State<InboundPage> {
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
items.sort((a, b) => a.product.compareTo(b.product));
|
||||
result = InboundRecord(
|
||||
number: initial?.number ?? _generateInboundNumber(processedAt.value),
|
||||
transactionNumber:
|
||||
@@ -936,6 +943,7 @@ class _InboundPageState extends State<InboundPage> {
|
||||
child: SuperportFormField(
|
||||
label: '창고',
|
||||
required: true,
|
||||
errorText: warehouseError,
|
||||
child: ShadSelect<String>(
|
||||
initialValue: warehouseController.text,
|
||||
selectedOptionBuilder: (context, value) =>
|
||||
@@ -943,7 +951,11 @@ class _InboundPageState extends State<InboundPage> {
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
warehouseController.text = value;
|
||||
setState(() {});
|
||||
setState(() {
|
||||
if (warehouseError != null) {
|
||||
warehouseError = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
options: _warehouseOptions
|
||||
@@ -962,6 +974,7 @@ class _InboundPageState extends State<InboundPage> {
|
||||
child: SuperportFormField(
|
||||
label: '상태',
|
||||
required: true,
|
||||
errorText: statusError,
|
||||
child: ShadSelect<String>(
|
||||
initialValue: statusValue.value,
|
||||
selectedOptionBuilder: (context, value) =>
|
||||
@@ -969,7 +982,11 @@ class _InboundPageState extends State<InboundPage> {
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
statusValue.value = value;
|
||||
setState(() {});
|
||||
setState(() {
|
||||
if (statusError != null) {
|
||||
statusError = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
enabled: initial?.status != '승인완료',
|
||||
@@ -1525,11 +1542,15 @@ class _LineItemFieldErrors {
|
||||
|
||||
_InboundFormValidation _validateInboundForm({
|
||||
required TextEditingController writerController,
|
||||
required String warehouseValue,
|
||||
required String statusValue,
|
||||
required List<_LineItemDraft> drafts,
|
||||
required Map<_LineItemDraft, _LineItemFieldErrors> lineErrors,
|
||||
}) {
|
||||
var isValid = true;
|
||||
String? writerError;
|
||||
String? warehouseError;
|
||||
String? statusError;
|
||||
String? headerNotice;
|
||||
|
||||
if (writerController.text.trim().isEmpty) {
|
||||
@@ -1537,12 +1558,24 @@ _InboundFormValidation _validateInboundForm({
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (warehouseValue.trim().isEmpty) {
|
||||
warehouseError = '창고를 선택하세요.';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (statusValue.trim().isEmpty) {
|
||||
statusError = '상태를 선택하세요.';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
var hasLineError = false;
|
||||
final seenProductKeys = <String>{};
|
||||
for (final draft in drafts) {
|
||||
final errors = lineErrors.putIfAbsent(draft, _LineItemFieldErrors.empty);
|
||||
errors.clearAll();
|
||||
|
||||
if (draft.product.text.trim().isEmpty) {
|
||||
final productText = draft.product.text.trim();
|
||||
if (productText.isEmpty) {
|
||||
errors.product = '제품을 입력하세요.';
|
||||
hasLineError = true;
|
||||
isValid = false;
|
||||
@@ -1552,8 +1585,15 @@ _InboundFormValidation _validateInboundForm({
|
||||
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(
|
||||
draft.quantity.text.trim() == '' ? '0' : draft.quantity.text.trim(),
|
||||
draft.quantity.text.trim().isEmpty ? '0' : draft.quantity.text.trim(),
|
||||
);
|
||||
if (quantity == null || quantity < 1) {
|
||||
errors.quantity = '수량은 1 이상 정수여야 합니다.';
|
||||
@@ -1576,6 +1616,8 @@ _InboundFormValidation _validateInboundForm({
|
||||
return _InboundFormValidation(
|
||||
isValid: isValid,
|
||||
writerError: writerError,
|
||||
warehouseError: warehouseError,
|
||||
statusError: statusError,
|
||||
headerNotice: headerNotice,
|
||||
);
|
||||
}
|
||||
@@ -1589,11 +1631,15 @@ class _InboundFormValidation {
|
||||
const _InboundFormValidation({
|
||||
required this.isValid,
|
||||
this.writerError,
|
||||
this.warehouseError,
|
||||
this.statusError,
|
||||
this.headerNotice,
|
||||
});
|
||||
|
||||
final bool isValid;
|
||||
final String? writerError;
|
||||
final String? warehouseError;
|
||||
final String? statusError;
|
||||
final String? headerNotice;
|
||||
}
|
||||
|
||||
|
||||
@@ -844,6 +844,8 @@ class _OutboundPageState extends State<OutboundPage> {
|
||||
|
||||
String? writerError;
|
||||
String? customerError;
|
||||
String? warehouseError;
|
||||
String? statusError;
|
||||
String? headerNotice;
|
||||
String customerSearchQuery = '';
|
||||
StateSetter? refreshForm;
|
||||
@@ -855,6 +857,8 @@ class _OutboundPageState extends State<OutboundPage> {
|
||||
void handleSubmit() {
|
||||
final validation = _validateOutboundForm(
|
||||
writerController: writerController,
|
||||
warehouseValue: warehouseController.text,
|
||||
statusValue: statusValue.value,
|
||||
customerController: customerController,
|
||||
drafts: drafts,
|
||||
lineErrors: lineErrors,
|
||||
@@ -862,6 +866,8 @@ class _OutboundPageState extends State<OutboundPage> {
|
||||
|
||||
writerError = validation.writerError;
|
||||
customerError = validation.customerError;
|
||||
warehouseError = validation.warehouseError;
|
||||
statusError = validation.statusError;
|
||||
headerNotice = validation.headerNotice;
|
||||
refreshForm?.call(() {});
|
||||
|
||||
@@ -882,6 +888,7 @@ class _OutboundPageState extends State<OutboundPage> {
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
items.sort((a, b) => a.product.compareTo(b.product));
|
||||
result = OutboundRecord(
|
||||
number: initial?.number ?? _generateOutboundNumber(processedAt.value),
|
||||
transactionNumber:
|
||||
@@ -948,6 +955,7 @@ class _OutboundPageState extends State<OutboundPage> {
|
||||
child: SuperportFormField(
|
||||
label: '창고',
|
||||
required: true,
|
||||
errorText: warehouseError,
|
||||
child: ShadSelect<String>(
|
||||
initialValue: warehouseController.text,
|
||||
selectedOptionBuilder: (context, value) =>
|
||||
@@ -955,7 +963,11 @@ class _OutboundPageState extends State<OutboundPage> {
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
warehouseController.text = value;
|
||||
setState(() {});
|
||||
setState(() {
|
||||
if (warehouseError != null) {
|
||||
warehouseError = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
options: [
|
||||
@@ -970,6 +982,7 @@ class _OutboundPageState extends State<OutboundPage> {
|
||||
child: SuperportFormField(
|
||||
label: '상태',
|
||||
required: true,
|
||||
errorText: statusError,
|
||||
child: ShadSelect<String>(
|
||||
initialValue: statusValue.value,
|
||||
selectedOptionBuilder: (context, value) =>
|
||||
@@ -977,7 +990,11 @@ class _OutboundPageState extends State<OutboundPage> {
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
statusValue.value = value;
|
||||
setState(() {});
|
||||
setState(() {
|
||||
if (statusError != null) {
|
||||
statusError = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
options: [
|
||||
@@ -1609,6 +1626,8 @@ double _parseCurrency(String input) {
|
||||
|
||||
_OutboundFormValidation _validateOutboundForm({
|
||||
required TextEditingController writerController,
|
||||
required String warehouseValue,
|
||||
required String statusValue,
|
||||
required ShadSelectController<String> customerController,
|
||||
required List<_OutboundLineItemDraft> drafts,
|
||||
required Map<_OutboundLineItemDraft, _OutboundLineErrors> lineErrors,
|
||||
@@ -1616,6 +1635,8 @@ _OutboundFormValidation _validateOutboundForm({
|
||||
var isValid = true;
|
||||
String? writerError;
|
||||
String? customerError;
|
||||
String? warehouseError;
|
||||
String? statusError;
|
||||
String? headerNotice;
|
||||
|
||||
if (writerController.text.trim().isEmpty) {
|
||||
@@ -1623,17 +1644,29 @@ _OutboundFormValidation _validateOutboundForm({
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (warehouseValue.trim().isEmpty) {
|
||||
warehouseError = '창고를 선택하세요.';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (statusValue.trim().isEmpty) {
|
||||
statusError = '상태를 선택하세요.';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (customerController.value.isEmpty) {
|
||||
customerError = '최소 1개의 고객사를 선택하세요.';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
var hasLineError = false;
|
||||
final seenProductKeys = <String>{};
|
||||
for (final draft in drafts) {
|
||||
final errors = lineErrors.putIfAbsent(draft, _OutboundLineErrors.empty);
|
||||
errors.clearAll();
|
||||
|
||||
if (draft.product.text.trim().isEmpty) {
|
||||
final productText = draft.product.text.trim();
|
||||
if (productText.isEmpty) {
|
||||
errors.product = '제품을 입력하세요.';
|
||||
hasLineError = true;
|
||||
isValid = false;
|
||||
@@ -1643,6 +1676,13 @@ _OutboundFormValidation _validateOutboundForm({
|
||||
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(
|
||||
draft.quantity.text.trim().isEmpty ? '0' : draft.quantity.text.trim(),
|
||||
);
|
||||
@@ -1668,6 +1708,8 @@ _OutboundFormValidation _validateOutboundForm({
|
||||
isValid: isValid,
|
||||
writerError: writerError,
|
||||
customerError: customerError,
|
||||
warehouseError: warehouseError,
|
||||
statusError: statusError,
|
||||
headerNotice: headerNotice,
|
||||
);
|
||||
}
|
||||
@@ -1677,12 +1719,16 @@ class _OutboundFormValidation {
|
||||
required this.isValid,
|
||||
this.writerError,
|
||||
this.customerError,
|
||||
this.warehouseError,
|
||||
this.statusError,
|
||||
this.headerNotice,
|
||||
});
|
||||
|
||||
final bool isValid;
|
||||
final String? writerError;
|
||||
final String? customerError;
|
||||
final String? warehouseError;
|
||||
final String? statusError;
|
||||
final String? headerNotice;
|
||||
}
|
||||
|
||||
|
||||
@@ -928,6 +928,8 @@ class _RentalPageState extends State<RentalPage> {
|
||||
String customerSearchQuery = '';
|
||||
String? writerError;
|
||||
String? customerError;
|
||||
String? warehouseError;
|
||||
String? statusError;
|
||||
String? headerNotice;
|
||||
void Function(VoidCallback fn)? refreshForm;
|
||||
|
||||
@@ -940,6 +942,8 @@ class _RentalPageState extends State<RentalPage> {
|
||||
void handleSubmit() {
|
||||
final validation = _validateRentalForm(
|
||||
writerController: writerController,
|
||||
warehouseValue: warehouseController.text,
|
||||
statusValue: statusValue.value,
|
||||
customerController: customerController,
|
||||
drafts: drafts,
|
||||
lineErrors: lineErrors,
|
||||
@@ -947,6 +951,8 @@ class _RentalPageState extends State<RentalPage> {
|
||||
|
||||
writerError = validation.writerError;
|
||||
customerError = validation.customerError;
|
||||
warehouseError = validation.warehouseError;
|
||||
statusError = validation.statusError;
|
||||
headerNotice = validation.headerNotice;
|
||||
refreshForm?.call(() {});
|
||||
|
||||
@@ -972,6 +978,7 @@ class _RentalPageState extends State<RentalPage> {
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
items.sort((a, b) => a.product.compareTo(b.product));
|
||||
result = RentalRecord(
|
||||
number: initial?.number ?? _generateRentalNumber(processedAt.value),
|
||||
transactionNumber:
|
||||
@@ -1062,8 +1069,10 @@ class _RentalPageState extends State<RentalPage> {
|
||||
),
|
||||
SizedBox(
|
||||
width: 220,
|
||||
child: _FormFieldLabel(
|
||||
child: SuperportFormField(
|
||||
label: '창고',
|
||||
required: true,
|
||||
errorText: warehouseError,
|
||||
child: ShadSelect<String>(
|
||||
initialValue: warehouseController.text,
|
||||
selectedOptionBuilder: (context, value) =>
|
||||
@@ -1071,7 +1080,11 @@ class _RentalPageState extends State<RentalPage> {
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
warehouseController.text = value;
|
||||
setState(() {});
|
||||
setState(() {
|
||||
if (warehouseError != null) {
|
||||
warehouseError = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
options: _warehouseOptions
|
||||
@@ -1087,8 +1100,10 @@ class _RentalPageState extends State<RentalPage> {
|
||||
),
|
||||
SizedBox(
|
||||
width: 240,
|
||||
child: _FormFieldLabel(
|
||||
child: SuperportFormField(
|
||||
label: '상태',
|
||||
required: true,
|
||||
errorText: statusError,
|
||||
child: ShadSelect<String>(
|
||||
initialValue: statusValue.value,
|
||||
selectedOptionBuilder: (context, value) =>
|
||||
@@ -1096,7 +1111,11 @@ class _RentalPageState extends State<RentalPage> {
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
statusValue.value = value;
|
||||
setState(() {});
|
||||
setState(() {
|
||||
if (statusError != null) {
|
||||
statusError = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
enabled: initial?.status != '완료',
|
||||
@@ -1833,6 +1852,8 @@ class RentalRecord {
|
||||
|
||||
_RentalFormValidation _validateRentalForm({
|
||||
required TextEditingController writerController,
|
||||
required String warehouseValue,
|
||||
required String statusValue,
|
||||
required ShadSelectController<String> customerController,
|
||||
required List<_RentalLineItemDraft> drafts,
|
||||
required Map<_RentalLineItemDraft, _RentalLineItemErrors> lineErrors,
|
||||
@@ -1840,6 +1861,8 @@ _RentalFormValidation _validateRentalForm({
|
||||
var isValid = true;
|
||||
String? writerError;
|
||||
String? customerError;
|
||||
String? warehouseError;
|
||||
String? statusError;
|
||||
String? headerNotice;
|
||||
|
||||
if (writerController.text.trim().isEmpty) {
|
||||
@@ -1847,17 +1870,29 @@ _RentalFormValidation _validateRentalForm({
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (warehouseValue.trim().isEmpty) {
|
||||
warehouseError = '창고를 선택하세요.';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (statusValue.trim().isEmpty) {
|
||||
statusError = '상태를 선택하세요.';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (customerController.value.isEmpty) {
|
||||
customerError = '최소 1개의 고객사를 선택하세요.';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
var hasLineError = false;
|
||||
final seenProductKeys = <String>{};
|
||||
for (final draft in drafts) {
|
||||
final errors = lineErrors.putIfAbsent(draft, _RentalLineItemErrors.empty);
|
||||
errors.clearAll();
|
||||
|
||||
if (draft.product.text.trim().isEmpty) {
|
||||
final productText = draft.product.text.trim();
|
||||
if (productText.isEmpty) {
|
||||
errors.product = '제품을 입력하세요.';
|
||||
hasLineError = true;
|
||||
isValid = false;
|
||||
@@ -1867,6 +1902,13 @@ _RentalFormValidation _validateRentalForm({
|
||||
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(
|
||||
draft.quantity.text.trim().isEmpty ? '0' : draft.quantity.text.trim(),
|
||||
);
|
||||
@@ -1892,6 +1934,8 @@ _RentalFormValidation _validateRentalForm({
|
||||
isValid: isValid,
|
||||
writerError: writerError,
|
||||
customerError: customerError,
|
||||
warehouseError: warehouseError,
|
||||
statusError: statusError,
|
||||
headerNotice: headerNotice,
|
||||
);
|
||||
}
|
||||
@@ -1901,12 +1945,16 @@ class _RentalFormValidation {
|
||||
required this.isValid,
|
||||
this.writerError,
|
||||
this.customerError,
|
||||
this.warehouseError,
|
||||
this.statusError,
|
||||
this.headerNotice,
|
||||
});
|
||||
|
||||
final bool isValid;
|
||||
final String? writerError;
|
||||
final String? customerError;
|
||||
final String? warehouseError;
|
||||
final String? statusError;
|
||||
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 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
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를 감싼 공통 테이블 래퍼.
|
||||
class SuperportTable extends StatelessWidget {
|
||||
const SuperportTable({
|
||||
@@ -14,6 +43,13 @@ class SuperportTable extends StatelessWidget {
|
||||
this.maxHeight,
|
||||
this.onRowTap,
|
||||
this.emptyLabel = '데이터가 없습니다.',
|
||||
this.sortableColumns,
|
||||
this.sortState,
|
||||
this.onSortChanged,
|
||||
this.pagination,
|
||||
this.onPageChange,
|
||||
this.onPageSizeChange,
|
||||
this.isLoading = false,
|
||||
}) : _columns = columns,
|
||||
_rows = rows,
|
||||
_headerCells = null,
|
||||
@@ -28,6 +64,13 @@ class SuperportTable extends StatelessWidget {
|
||||
this.maxHeight,
|
||||
this.onRowTap,
|
||||
this.emptyLabel = '데이터가 없습니다.',
|
||||
this.sortableColumns,
|
||||
this.sortState,
|
||||
this.onSortChanged,
|
||||
this.pagination,
|
||||
this.onPageChange,
|
||||
this.onPageSizeChange,
|
||||
this.isLoading = false,
|
||||
}) : _columns = null,
|
||||
_rows = null,
|
||||
_headerCells = header,
|
||||
@@ -42,6 +85,13 @@ class SuperportTable extends StatelessWidget {
|
||||
final double? maxHeight;
|
||||
final void Function(int index)? onRowTap;
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
@@ -60,7 +110,7 @@ class SuperportTable extends StatelessWidget {
|
||||
if (header == null) {
|
||||
throw StateError('header cells must not be null when using fromCells');
|
||||
}
|
||||
headerCells = header;
|
||||
headerCells = [...header];
|
||||
tableRows = rows;
|
||||
} else {
|
||||
final rows = _rows;
|
||||
@@ -71,13 +121,33 @@ class SuperportTable extends StatelessWidget {
|
||||
child: Center(child: Text(emptyLabel, style: theme.textTheme.muted)),
|
||||
);
|
||||
}
|
||||
headerCells = _columns!
|
||||
.map(
|
||||
(cell) => cell is ShadTableCell
|
||||
? cell
|
||||
: ShadTableCell.header(child: cell),
|
||||
)
|
||||
.toList();
|
||||
final columns = _columns!;
|
||||
final sortable = sortableColumns ?? const <int>{};
|
||||
headerCells = <ShadTableCell>[];
|
||||
for (var i = 0; i < columns.length; i++) {
|
||||
final columnWidget = columns[i];
|
||||
if (columnWidget is ShadTableCell) {
|
||||
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 = [
|
||||
for (final row in rows)
|
||||
row
|
||||
@@ -98,7 +168,7 @@ class SuperportTable extends StatelessWidget {
|
||||
: math.min(estimatedHeight, maxHeight!),
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
final tableView = SizedBox(
|
||||
height: effectiveHeight,
|
||||
child: ShadTable.list(
|
||||
header: headerCells,
|
||||
@@ -109,5 +179,140 @@ class SuperportTable extends StatelessWidget {
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user