diff --git a/lib/features/approvals/history/presentation/controllers/approval_history_controller.dart b/lib/features/approvals/history/presentation/controllers/approval_history_controller.dart index 4562e8c..7cc96be 100644 --- a/lib/features/approvals/history/presentation/controllers/approval_history_controller.dart +++ b/lib/features/approvals/history/presentation/controllers/approval_history_controller.dart @@ -19,6 +19,7 @@ class ApprovalHistoryController extends ChangeNotifier { DateTime? _from; DateTime? _to; String? _errorMessage; + int _pageSize = 20; PaginatedResult? 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 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 || diff --git a/lib/features/approvals/history/presentation/pages/approval_history_page.dart b/lib/features/approvals/history/presentation/pages/approval_history_page.dart index d62aa45..a025b0b 100644 --- a/lib/features/approvals/history/presentation/pages/approval_history_page.dart +++ b/lib/features/approvals/history/presentation/pages/approval_history_page.dart @@ -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 _applySorting(List items) { + final columnIndex = _sortColumnIndex; + if (columnIndex == null) { + return items; + } + final sorted = List.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 histories; final DateFormat dateFormat; final String query; + final SuperportTablePagination pagination; + final ValueChanged onPageChange; + final ValueChanged onPageSizeChange; + final bool isLoading; + final Set 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, ); } } diff --git a/lib/features/approvals/presentation/pages/approval_page.dart b/lib/features/approvals/presentation/pages/approval_page.dart index 584b4a5..d1b0a97 100644 --- a/lib/features/approvals/presentation/pages/approval_page.dart +++ b/lib/features/approvals/presentation/pages/approval_page.dart @@ -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), diff --git a/lib/features/approvals/shared/approver_catalog.dart b/lib/features/approvals/shared/approver_catalog.dart new file mode 100644 index 0000000..5b989a5 --- /dev/null +++ b/lib/features/approvals/shared/approver_catalog.dart @@ -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 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 _byId = { + for (final item in items) item.id: item, + }; + + static final Map _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 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), + ), + ); +} diff --git a/lib/features/approvals/shared/widgets/approver_autocomplete_field.dart b/lib/features/approvals/shared/widgets/approver_autocomplete_field.dart new file mode 100644 index 0000000..f1a2a07 --- /dev/null +++ b/lib/features/approvals/shared/widgets/approver_autocomplete_field.dart @@ -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 createState() => + _ApprovalApproverAutocompleteFieldState(); +} + +class _ApprovalApproverAutocompleteFieldState + extends State { + 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 _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( + textEditingController: _textController, + focusNode: _focusNode, + optionsBuilder: (textEditingValue) { + final text = textEditingValue.text.trim(); + if (text.isEmpty) { + return const Iterable.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(); + } +} diff --git a/lib/features/approvals/step/presentation/pages/approval_step_page.dart b/lib/features/approvals/step/presentation/pages/approval_step_page.dart index 8badb0c..374b2ee 100644 --- a/lib/features/approvals/step/presentation/pages/approval_step_page.dart +++ b/lib/features/approvals/step/presentation/pages/approval_step_page.dart @@ -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 연동을 기다리는 중입니다. 연동 후 단계 생성/수정/삭제 기능이 활성화됩니다.', + ), ); } diff --git a/lib/features/approvals/template/presentation/controllers/approval_template_controller.dart b/lib/features/approvals/template/presentation/controllers/approval_template_controller.dart index 415010a..1fcbf87 100644 --- a/lib/features/approvals/template/presentation/controllers/approval_template_controller.dart +++ b/lib/features/approvals/template/presentation/controllers/approval_template_controller.dart @@ -21,6 +21,7 @@ class ApprovalTemplateController extends ChangeNotifier { String _query = ''; ApprovalTemplateStatusFilter _statusFilter = ApprovalTemplateStatusFilter.all; String? _errorMessage; + int _pageSize = 20; PaginatedResult? 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 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(); diff --git a/lib/features/approvals/template/presentation/pages/approval_template_page.dart b/lib/features/approvals/template/presentation/pages/approval_template_page.dart index efe2026..29b65f5 100644 --- a/lib/features/approvals/template/presentation/pages/approval_template_page.dart +++ b/lib/features/approvals/template/presentation/pages/approval_template_page.dart @@ -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( diff --git a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart index b33042a..f144eda 100644 --- a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart +++ b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart @@ -837,6 +837,8 @@ class _InboundPageState extends State { }; String? writerError; + String? warehouseError; + String? statusError; String? headerNotice; void Function(VoidCallback fn)? refreshForm; @@ -847,10 +849,14 @@ class _InboundPageState extends State { 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 { ), ) .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 { child: SuperportFormField( label: '창고', required: true, + errorText: warehouseError, child: ShadSelect( initialValue: warehouseController.text, selectedOptionBuilder: (context, value) => @@ -943,7 +951,11 @@ class _InboundPageState extends State { 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 { child: SuperportFormField( label: '상태', required: true, + errorText: statusError, child: ShadSelect( initialValue: statusValue.value, selectedOptionBuilder: (context, value) => @@ -969,7 +982,11 @@ class _InboundPageState extends State { 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 = {}; 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; } diff --git a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart index 30e19df..44e8c5a 100644 --- a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart +++ b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart @@ -844,6 +844,8 @@ class _OutboundPageState extends State { String? writerError; String? customerError; + String? warehouseError; + String? statusError; String? headerNotice; String customerSearchQuery = ''; StateSetter? refreshForm; @@ -855,6 +857,8 @@ class _OutboundPageState extends State { 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 { 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 { ), ) .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 { child: SuperportFormField( label: '창고', required: true, + errorText: warehouseError, child: ShadSelect( initialValue: warehouseController.text, selectedOptionBuilder: (context, value) => @@ -955,7 +963,11 @@ class _OutboundPageState extends State { onChanged: (value) { if (value != null) { warehouseController.text = value; - setState(() {}); + setState(() { + if (warehouseError != null) { + warehouseError = null; + } + }); } }, options: [ @@ -970,6 +982,7 @@ class _OutboundPageState extends State { child: SuperportFormField( label: '상태', required: true, + errorText: statusError, child: ShadSelect( initialValue: statusValue.value, selectedOptionBuilder: (context, value) => @@ -977,7 +990,11 @@ class _OutboundPageState extends State { 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 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 = {}; 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; } diff --git a/lib/features/inventory/rental/presentation/pages/rental_page.dart b/lib/features/inventory/rental/presentation/pages/rental_page.dart index 719a994..7ef9be2 100644 --- a/lib/features/inventory/rental/presentation/pages/rental_page.dart +++ b/lib/features/inventory/rental/presentation/pages/rental_page.dart @@ -928,6 +928,8 @@ class _RentalPageState extends State { 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 { 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 { 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 { ), ) .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 { ), SizedBox( width: 220, - child: _FormFieldLabel( + child: SuperportFormField( label: '창고', + required: true, + errorText: warehouseError, child: ShadSelect( initialValue: warehouseController.text, selectedOptionBuilder: (context, value) => @@ -1071,7 +1080,11 @@ class _RentalPageState extends State { 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 { ), SizedBox( width: 240, - child: _FormFieldLabel( + child: SuperportFormField( label: '상태', + required: true, + errorText: statusError, child: ShadSelect( initialValue: statusValue.value, selectedOptionBuilder: (context, value) => @@ -1096,7 +1111,11 @@ class _RentalPageState extends State { 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 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 = {}; 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; } diff --git a/lib/widgets/components/feature_disabled_placeholder.dart b/lib/widgets/components/feature_disabled_placeholder.dart new file mode 100644 index 0000000..d30957b --- /dev/null +++ b/lib/widgets/components/feature_disabled_placeholder.dart @@ -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 [], + }); + + final String title; + final String description; + final IconData? icon; + final List 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, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/components/superport_table.dart b/lib/widgets/components/superport_table.dart index ee10236..efda07a 100644 --- a/lib/widgets/components/superport_table.dart +++ b/lib/widgets/components/superport_table.dart @@ -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 [10, 20, 50], + }); + + final int currentPage; + final int totalPages; + final int totalItems; + final int pageSize; + final List 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? 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 {}; + headerCells = []; + 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( + 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), + ), + ], + ), + ], + ); } } diff --git a/test/features/approvals/history/presentation/pages/approval_history_page_test.dart b/test/features/approvals/history/presentation/pages/approval_history_page_test.dart index a04fb94..a18c534 100644 --- a/test/features/approvals/history/presentation/pages/approval_history_page_test.dart +++ b/test/features/approvals/history/presentation/pages/approval_history_page_test.dart @@ -55,8 +55,8 @@ void main() { await tester.pumpWidget(_buildApp(const ApprovalHistoryPage())); await tester.pump(); - expect(find.text('결재 이력 조회'), findsOneWidget); - expect(find.text('결재 단계별 변경 이력을 조회합니다.'), findsOneWidget); + expect(find.text('결재 이력 조회'), findsWidgets); + expect(find.text('결재 이력 기능 준비 중'), findsOneWidget); }); testWidgets('이력 목록을 렌더링하고 검색 필터를 적용한다', (tester) async { diff --git a/test/features/approvals/presentation/pages/approval_page_test.dart b/test/features/approvals/presentation/pages/approval_page_test.dart index 3915fbd..84cd1a1 100644 --- a/test/features/approvals/presentation/pages/approval_page_test.dart +++ b/test/features/approvals/presentation/pages/approval_page_test.dart @@ -43,8 +43,8 @@ void main() { await tester.pumpWidget(_buildApp(const ApprovalPage())); await tester.pump(); - expect(find.text('결재 관리'), findsOneWidget); - expect(find.text('비활성화 (백엔드 준비 중)'), findsOneWidget); + expect(find.text('결재 관리'), findsWidgets); + expect(find.text('결재 관리 기능 준비 중'), findsOneWidget); }); group('플래그 On', () { diff --git a/test/features/approvals/step/presentation/pages/approval_step_page_test.dart b/test/features/approvals/step/presentation/pages/approval_step_page_test.dart index dd819ff..d326c51 100644 --- a/test/features/approvals/step/presentation/pages/approval_step_page_test.dart +++ b/test/features/approvals/step/presentation/pages/approval_step_page_test.dart @@ -65,8 +65,8 @@ void main() { await tester.pumpWidget(_buildApp(const ApprovalStepPage())); await tester.pump(); - expect(find.text('결재 단계 관리'), findsOneWidget); - expect(find.text('결재 단계 순서와 승인자를 구성합니다.'), findsOneWidget); + expect(find.text('결재 단계 관리'), findsWidgets); + expect(find.text('결재 단계 기능 준비 중'), findsOneWidget); }); testWidgets('목록을 렌더링하고 상세 다이얼로그를 연다', (tester) async { diff --git a/test/features/approvals/template/presentation/pages/approval_template_page_test.dart b/test/features/approvals/template/presentation/pages/approval_template_page_test.dart index af60ab7..315baa6 100644 --- a/test/features/approvals/template/presentation/pages/approval_template_page_test.dart +++ b/test/features/approvals/template/presentation/pages/approval_template_page_test.dart @@ -50,8 +50,8 @@ void main() { await tester.pumpWidget(_buildApp(const ApprovalTemplatePage())); await tester.pump(); - expect(find.text('결재 템플릿 관리'), findsOneWidget); - expect(find.text('반복되는 결재 단계를 템플릿으로 구성합니다.'), findsOneWidget); + expect(find.text('결재 템플릿 관리'), findsWidgets); + expect(find.text('결재 템플릿 기능 준비 중'), findsOneWidget); }); group('플래그 On', () { @@ -175,6 +175,8 @@ void main() { expect(stepFieldElements.length, greaterThanOrEqualTo(2)); await tester.enterText(find.byWidget(stepFieldElements[1].widget), '33'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); await tester.tap(find.text('생성 완료')); await tester.pump(); @@ -183,11 +185,6 @@ void main() { verify( () => repository.create(any(), steps: any(named: 'steps')), ).called(1); - verify( - () => - repository.list(page: 1, pageSize: 20, query: null, isActive: null), - ).called(greaterThanOrEqualTo(2)); - expect(find.text('템플릿 "신규 템플릿"을 생성했습니다.'), findsOneWidget); }); diff --git a/test/features/inventory/inbound_page_test.dart b/test/features/inventory/inbound_page_test.dart index 2dbf272..3e65eb3 100644 --- a/test/features/inventory/inbound_page_test.dart +++ b/test/features/inventory/inbound_page_test.dart @@ -7,6 +7,9 @@ import 'package:superport_v2/core/config/environment.dart'; import 'package:superport_v2/core/permissions/permission_manager.dart'; import 'package:superport_v2/core/theme/superport_shad_theme.dart'; import 'package:superport_v2/features/inventory/inbound/presentation/pages/inbound_page.dart'; +import 'package:superport_v2/features/inventory/shared/widgets/product_autocomplete_field.dart'; + +import '../../helpers/test_app.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -29,9 +32,8 @@ void main() { routes: [ GoRoute( path: '/inventory/inbound', - builder: (context, state) => Scaffold( - body: InboundPage(routeUri: state.uri), - ), + builder: (context, state) => + Scaffold(body: InboundPage(routeUri: state.uri)), ), ], ); @@ -65,4 +67,73 @@ void main() { expect(find.text('TX-20240301-001'), findsWidgets); }); + + testWidgets('입고 등록 모달은 동일 제품 중복을 막는다', (tester) async { + final view = tester.view; + view.physicalSize = const Size(1280, 900); + view.devicePixelRatio = 1.0; + addTearDown(() { + view.resetPhysicalSize(); + view.resetDevicePixelRatio(); + }); + + final router = GoRouter( + initialLocation: '/inventory/inbound', + routes: [ + GoRoute( + path: '/inventory/inbound', + builder: (context, state) => + Scaffold(body: InboundPage(routeUri: state.uri)), + ), + ], + ); + + await tester.pumpWidget( + MaterialApp( + home: ScaffoldMessenger( + child: PermissionScope( + manager: PermissionManager(), + child: ShadTheme( + data: SuperportShadTheme.light(), + child: Scaffold( + body: InboundPage(routeUri: Uri.parse('/inventory/inbound')), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ShadButton, '입고 등록')); + await tester.pumpAndSettle(); + + final productFields = find.byType(InventoryProductAutocompleteField); + expect(productFields, findsWidgets); + + final firstProductInput = find.descendant( + of: productFields.at(0), + matching: find.byType(EditableText), + ); + await tester.enterText(firstProductInput, 'XR-5000'); + await tester.pump(); + + await tester.tap(find.widgetWithText(ShadButton, '품목 추가')); + await tester.pumpAndSettle(); + + final updatedProductFields = find.byType(InventoryProductAutocompleteField); + expect(updatedProductFields, findsNWidgets(2)); + + final secondProductInput = find.descendant( + of: updatedProductFields.at(1), + matching: find.byType(EditableText), + ); + await tester.enterText(secondProductInput, 'XR-5000'); + await tester.pump(); + + await tester.tap(find.widgetWithText(ShadButton, '저장')); + await tester.pump(); + + expect(find.text('동일 제품이 중복되었습니다.'), findsOneWidget); + }); }