결재 비활성 안내 개선 및 테이블 기능 보강

This commit is contained in:
JiWoong Sul
2025-09-29 15:49:06 +09:00
parent fef7108479
commit 98724762ec
18 changed files with 1134 additions and 297 deletions

View File

@@ -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 ||

View File

@@ -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,
);
}
}

View File

@@ -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),

View 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),
),
);
}

View File

@@ -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();
}
}

View File

@@ -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 연동을 기다리는 중입니다. 연동 후 단계 생성/수정/삭제 기능이 활성화됩니다.',
),
);
}

View File

@@ -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();

View File

@@ -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(

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}