diff --git a/integration_test/approvals_flow_test.dart b/integration_test/approvals_flow_test.dart index 5bd2a6a..87e84a5 100644 --- a/integration_test/approvals_flow_test.dart +++ b/integration_test/approvals_flow_test.dart @@ -72,6 +72,7 @@ void main() { final controller = ApprovalController( approvalRepository: approvalRepository, templateRepository: templateRepository, + transactionRepository: stockRepository, ); final approveUseCase = ApproveApprovalUseCase( repository: approvalRepository, diff --git a/lib/core/navigation/menu_route_definitions.dart b/lib/core/navigation/menu_route_definitions.dart index 2d3d6d0..5231fea 100644 --- a/lib/core/navigation/menu_route_definitions.dart +++ b/lib/core/navigation/menu_route_definitions.dart @@ -100,7 +100,7 @@ final List menuRouteDefinitions = [ defaultLabel: '재고 현황', icon: lucide.LucideIcons.chartNoAxesColumnIncreasing, builder: (context, state) => InventorySummaryPage(routeUri: state.uri), - defaultOrder: 20, + defaultOrder: 11, extraRequirements: const [ PermissionRequirement(resource: PermissionResources.inventoryScope), ], @@ -112,7 +112,7 @@ final List menuRouteDefinitions = [ defaultLabel: '입고', icon: lucide.LucideIcons.packagePlus, builder: (context, state) => InboundPage(routeUri: state.uri), - defaultOrder: 21, + defaultOrder: 12, ), MenuRouteDefinition( menuCode: 'inventory.issues', @@ -121,7 +121,7 @@ final List menuRouteDefinitions = [ defaultLabel: '출고', icon: lucide.LucideIcons.packageMinus, builder: (context, state) => OutboundPage(routeUri: state.uri), - defaultOrder: 22, + defaultOrder: 13, ), MenuRouteDefinition( menuCode: 'inventory.rentals', @@ -129,7 +129,7 @@ final List menuRouteDefinitions = [ defaultLabel: '대여', icon: lucide.LucideIcons.handshake, builder: (context, state) => RentalPage(routeUri: state.uri), - defaultOrder: 23, + defaultOrder: 14, ), MenuRouteDefinition( menuCode: 'inventory.vendors', diff --git a/lib/core/permissions/permission_scope_mapper.dart b/lib/core/permissions/permission_scope_mapper.dart new file mode 100644 index 0000000..20b0b0b --- /dev/null +++ b/lib/core/permissions/permission_scope_mapper.dart @@ -0,0 +1,97 @@ +import 'permission_manager.dart'; +import 'permission_resources.dart'; + +/// 서버가 내려주는 scope 권한 코드를 실사용 리소스 권한으로 변환한다. +class PermissionScopeMapper { + const PermissionScopeMapper._(); + + /// scope: 형식의 권한에서 [PermissionManager]가 이해할 수 있는 리소스 맵을 생성한다. + static Map>? map(String scope) { + final code = _normalize(scope); + if (code.isEmpty) { + return null; + } + final definition = _definitions[code]; + if (definition == null || definition.isEmpty) { + return null; + } + final mapped = >{}; + for (final entry in definition.entries) { + mapped[entry.key] = entry.value.toSet(); + } + return mapped; + } + + static String _normalize(String value) { + final trimmed = value.trim().toLowerCase(); + if (trimmed.isEmpty) { + return ''; + } + if (trimmed.startsWith('scope:')) { + return trimmed.substring('scope:'.length); + } + return trimmed; + } + + static const Map>> _definitions = { + 'approval.approve': { + PermissionResources.approvals: {PermissionAction.approve}, + }, + 'approvals': { + PermissionResources.approvals: {PermissionAction.view}, + }, + 'approvals.history': { + PermissionResources.approvalHistories: {PermissionAction.view}, + }, + 'approvals.steps': { + PermissionResources.approvalSteps: {PermissionAction.view}, + }, + 'approvals.templates': { + PermissionResources.approvalTemplates: {PermissionAction.view}, + }, + 'dashboard': { + PermissionResources.dashboard: {PermissionAction.view}, + }, + 'dashboard.view': { + PermissionResources.dashboard: {PermissionAction.view}, + }, + 'approval.view_all': { + PermissionResources.approvals: {PermissionAction.view}, + PermissionResources.approvalSteps: {PermissionAction.view}, + PermissionResources.approvalHistories: {PermissionAction.view}, + }, + 'approval.manage': { + PermissionResources.approvals: { + PermissionAction.view, + PermissionAction.create, + PermissionAction.edit, + PermissionAction.delete, + }, + PermissionResources.approvalSteps: { + PermissionAction.view, + PermissionAction.create, + PermissionAction.edit, + PermissionAction.delete, + }, + PermissionResources.approvalHistories: {PermissionAction.view}, + PermissionResources.approvalTemplates: { + PermissionAction.view, + PermissionAction.create, + PermissionAction.edit, + PermissionAction.delete, + }, + }, + 'inventory.view': { + PermissionResources.inventorySummary: {PermissionAction.view}, + }, + 'inventory.receipts': { + PermissionResources.stockTransactions: {PermissionAction.view}, + }, + 'inventory.issues': { + PermissionResources.stockTransactions: {PermissionAction.view}, + }, + 'inventory.rentals': { + PermissionResources.stockTransactions: {PermissionAction.view}, + }, + }; +} diff --git a/lib/features/approvals/presentation/dialogs/approval_detail_dialog.dart b/lib/features/approvals/presentation/dialogs/approval_detail_dialog.dart index 748e383..bf84781 100644 --- a/lib/features/approvals/presentation/dialogs/approval_detail_dialog.dart +++ b/lib/features/approvals/presentation/dialogs/approval_detail_dialog.dart @@ -11,6 +11,7 @@ import '../../../../widgets/components/superport_detail_dialog.dart'; import '../../domain/entities/approval.dart'; import '../../domain/entities/approval_template.dart'; import '../../presentation/controllers/approval_controller.dart'; +import '../../../inventory/transactions/domain/entities/stock_transaction.dart'; /// 결재 상세 다이얼로그를 표시한다. /// @@ -22,6 +23,7 @@ Future showApprovalDetailDialog({ required intl.DateFormat dateFormat, required bool canPerformStepActions, required bool canApplyTemplate, + required int? currentUserId, }) { return showSuperportDialog( context: context, @@ -57,6 +59,7 @@ Future showApprovalDetailDialog({ dateFormat: dateFormat, canPerformStepActions: canPerformStepActions, canApplyTemplate: canApplyTemplate, + currentUserId: currentUserId, ), constraints: const BoxConstraints(maxWidth: 880), barrierDismissible: true, @@ -70,12 +73,14 @@ class ApprovalDetailDialogView extends StatefulWidget { required this.dateFormat, required this.canPerformStepActions, required this.canApplyTemplate, + required this.currentUserId, }); final ApprovalController controller; final intl.DateFormat dateFormat; final bool canPerformStepActions; final bool canApplyTemplate; + final int? currentUserId; @override State createState() => @@ -191,6 +196,7 @@ class _ApprovalDetailDialogViewState extends State { final requireNote = type == ApprovalStepActionType.comment; final noteController = TextEditingController(); String? errorText; + StateSetter? setErrorState; final confirmed = await showSuperportDialog( context: context, title: _dialogTitle(type), @@ -198,16 +204,17 @@ class _ApprovalDetailDialogViewState extends State { onSubmit: () { final note = noteController.text.trim(); if (requireNote && note.isEmpty) { - errorText = '비고를 입력하세요.'; + setErrorState?.call(() => errorText = '비고를 입력하세요.'); return; } Navigator.of( context, rootNavigator: true, - ).maybePop(note.isEmpty ? null : note); + ).pop(note.isEmpty ? '' : note); }, body: StatefulBuilder( builder: (dialogContext, setState) { + setErrorState = setState; final theme = ShadTheme.of(dialogContext); final materialTheme = Theme.of(dialogContext); return Column( @@ -256,21 +263,20 @@ class _ApprovalDetailDialogViewState extends State { ), actions: [ ShadButton.ghost( - onPressed: () => - Navigator.of(context, rootNavigator: true).maybePop(), + onPressed: () => Navigator.of(context, rootNavigator: true).pop(), child: const Text('취소'), ), ShadButton( onPressed: () { final note = noteController.text.trim(); if (requireNote && note.isEmpty) { - errorText = '비고를 입력하세요.'; + setErrorState?.call(() => errorText = '비고를 입력하세요.'); return; } Navigator.of( context, rootNavigator: true, - ).maybePop(note.isEmpty ? null : note); + ).pop(note.isEmpty ? '' : note); }, child: Text(_dialogConfirmLabel(type)), ), @@ -279,7 +285,10 @@ class _ApprovalDetailDialogViewState extends State { return confirmed; } - List _buildSummaryBadges(Approval approval) { + List _buildSummaryBadges( + Approval approval, + StockTransaction? transaction, + ) { final badges = [ShadBadge(child: Text(approval.status.name))]; badges.add( approval.isActive @@ -289,11 +298,16 @@ class _ApprovalDetailDialogViewState extends State { if (approval.isDeleted) { badges.add(const ShadBadge.destructive(child: Text('삭제됨'))); } + if (transaction != null) { + badges.add(ShadBadge.outline(child: Text(transaction.type.name))); + badges.add(ShadBadge.outline(child: Text(transaction.warehouse.name))); + } return badges; } List _buildMetadata({ required Approval approval, + required StockTransaction? transaction, required bool canProceed, required String? cannotProceedReason, }) { @@ -339,6 +353,37 @@ class _ApprovalDetailDialogViewState extends State { ? '-' : approval.note!.trim(), ), + if (transaction != null) ...[ + SuperportDetailMetadata.text( + label: '입출고 유형', + value: transaction.type.name, + ), + SuperportDetailMetadata.text( + label: '처리일자', + value: _formatDate(transaction.transactionDate), + ), + SuperportDetailMetadata.text( + label: '창고', + value: transaction.warehouse.name, + ), + SuperportDetailMetadata.text( + label: '작성자', + value: + '${transaction.createdBy.name} (${transaction.createdBy.employeeNo})', + ), + SuperportDetailMetadata.text( + label: '품목 수', + value: '${transaction.itemCount}', + ), + SuperportDetailMetadata.text( + label: '총 수량', + value: '${transaction.totalQuantity}', + ), + SuperportDetailMetadata.text( + label: '예상 반납일', + value: _formatDate(transaction.expectedReturnDate), + ), + ], if (!canProceed && cannotProceedReason != null) SuperportDetailMetadata.text( label: '진행 제한 사유', @@ -380,6 +425,10 @@ class _ApprovalDetailDialogViewState extends State { final processingStepId = widget.controller.processingStepId; final canProceed = widget.controller.canProceedSelected; final cannotProceedReason = widget.controller.cannotProceedReason; + final transaction = widget.controller.selectedTransaction; + final isTransactionLoading = + widget.controller.isLoadingTransactionDetail; + final transactionError = widget.controller.transactionError; _ensureTemplateSelectionValid(templates); final theme = ShadTheme.of(context); @@ -401,16 +450,35 @@ class _ApprovalDetailDialogViewState extends State { '트랜잭션 ${approval.transactionNo}', style: theme.textTheme.muted, ), + if (transaction != null) ...[ + const SizedBox(height: 2), + Text( + '${transaction.type.name} · ${transaction.warehouse.name}', + style: theme.textTheme.small, + ), + ], ], ); - final summaryBadges = _buildSummaryBadges(approval); + final summaryBadges = _buildSummaryBadges(approval, transaction); final metadata = _buildMetadata( approval: approval, + transaction: transaction, canProceed: canProceed, cannotProceedReason: cannotProceedReason, ); final sections = [ + SuperportDetailDialogSection( + id: 'transaction', + label: '전표', + icon: lucide.LucideIcons.clipboardList, + builder: (_) => _ApprovalTransactionSection( + transaction: transaction, + isLoading: isTransactionLoading, + errorMessage: transactionError, + dateFormat: widget.dateFormat, + ), + ), SuperportDetailDialogSection( key: const ValueKey('approval_detail_tab_steps'), id: 'steps', @@ -426,12 +494,11 @@ class _ApprovalDetailDialogViewState extends State { selectedTemplateId: _selectedTemplateId, canApplyTemplate: widget.canApplyTemplate, canPerformStepActions: widget.canPerformStepActions, + currentUserId: widget.currentUserId, hasActionOptions: hasActionOptions, isLoadingActions: isLoadingActions, isPerformingAction: isPerformingAction, processingStepId: processingStepId, - canProceed: canProceed, - cannotProceedReason: cannotProceedReason, onSelectTemplate: (id) => setState(() { _selectedTemplateId = id; }), @@ -479,12 +546,11 @@ class _ApprovalStepSection extends StatelessWidget { required this.selectedTemplateId, required this.canApplyTemplate, required this.canPerformStepActions, + required this.currentUserId, required this.hasActionOptions, required this.isLoadingActions, required this.isPerformingAction, required this.processingStepId, - required this.canProceed, - required this.cannotProceedReason, required this.onSelectTemplate, required this.onApplyTemplate, required this.onReloadTemplates, @@ -500,12 +566,11 @@ class _ApprovalStepSection extends StatelessWidget { final int? selectedTemplateId; final bool canApplyTemplate; final bool canPerformStepActions; + final int? currentUserId; final bool hasActionOptions; final bool isLoadingActions; final bool isPerformingAction; final int? processingStepId; - final bool canProceed; - final String? cannotProceedReason; final void Function(int?) onSelectTemplate; final Future Function(int templateId) onApplyTemplate; final Future Function() onReloadTemplates; @@ -552,11 +617,17 @@ class _ApprovalStepSection extends StatelessWidget { isPerformingAction && processingStepId != null && processingStepId == step.id; + final isAssignedToCurrentUser = + currentUserId != null && step.approver.id == currentUserId; + final currentStep = approval.currentStep; + final isCurrentStep = + currentStep != null && + currentStep.stepOrder == step.stepOrder; final disabledReason = _disabledReason( step, canPerformStepActions, - canProceed, - cannotProceedReason, + isAssignedToCurrentUser, + isCurrentStep, ); final enabled = disabledReason == null; return ShadCard( @@ -710,14 +781,20 @@ class _ApprovalStepSection extends StatelessWidget { String? _disabledReason( ApprovalStep step, bool canPerform, - bool canProceed, - String? cannotProceedReason, + bool isAssignedToCurrentUser, + bool isCurrentStep, ) { if (!canPerform) { return '결재 권한이 없어 단계 행위를 실행할 수 없습니다.'; } - if (!canProceed) { - return cannotProceedReason ?? '현재는 결재 단계를 진행할 수 없습니다.'; + if (!isAssignedToCurrentUser) { + return '해당 단계 승인자로 배정된 계정만 처리할 수 있습니다.'; + } + if (step.status.isTerminal) { + return '이미 ${step.status.name} 상태로 처리되었습니다.'; + } + if (!isCurrentStep && step.status.isBlockingNext) { + return '아직 이 단계의 처리 순서가 도달하지 않았습니다.'; } if (step.id == null) { return '단계 ID가 없어 행위를 수행할 수 없습니다.'; @@ -748,6 +825,283 @@ class _ApprovalStepSection extends StatelessWidget { } } +class _ApprovalTransactionSection extends StatelessWidget { + const _ApprovalTransactionSection({ + required this.transaction, + required this.isLoading, + required this.errorMessage, + required this.dateFormat, + }); + + final StockTransaction? transaction; + final bool isLoading; + final String? errorMessage; + final intl.DateFormat dateFormat; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + if (isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (errorMessage != null) { + return ShadCard( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + errorMessage!, + style: theme.textTheme.small.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + ); + } + final txn = transaction; + if (txn == null) { + return ShadCard( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text('연결된 트랜잭션 정보를 찾을 수 없습니다.', style: theme.textTheme.muted), + ), + ); + } + final children = [ + _buildHeaderCard(theme, txn), + const SizedBox(height: 16), + _buildLineCard(theme, txn), + const SizedBox(height: 16), + _buildCustomerCard(theme, txn), + ]; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ); + } + + Widget _buildHeaderCard(ShadThemeData theme, StockTransaction transaction) { + return ShadCard( + title: Text('전표 정보', style: theme.textTheme.h4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _infoRow(theme, label: '상태', value: transaction.status.name), + _infoRow( + theme, + label: '처리일자', + value: _formatDate(transaction.transactionDate), + ), + _infoRow( + theme, + label: '창고', + value: + '${transaction.warehouse.name} ' + '(${transaction.warehouse.code})', + ), + _infoRow( + theme, + label: '작성자', + value: + '${transaction.createdBy.name} (${transaction.createdBy.employeeNo})', + ), + _infoRow(theme, label: '품목 수', value: '${transaction.itemCount}'), + _infoRow( + theme, + label: '총 수량', + value: _quantityFormat.format(transaction.totalQuantity), + ), + _infoRow( + theme, + label: '고객사', + value: _customerSummary(transaction.customers), + ), + _infoRow( + theme, + label: '예상 반납일', + value: _formatDate(transaction.expectedReturnDate), + ), + _infoRow( + theme, + label: '비고', + value: _formatOptional(transaction.note), + ), + ], + ), + ); + } + + Widget _buildLineCard(ShadThemeData theme, StockTransaction transaction) { + if (transaction.lines.isEmpty) { + return ShadCard( + title: Text('품목', style: theme.textTheme.h4), + child: Padding( + padding: const EdgeInsets.all(16), + child: Text('등록된 품목이 없습니다.', style: theme.textTheme.muted), + ), + ); + } + final header = [ + const ShadTableCell.header(child: Text('라인')), + const ShadTableCell.header(child: Text('제품')), + const ShadTableCell.header(child: Text('수량')), + const ShadTableCell.header(child: Text('단위')), + const ShadTableCell.header(child: Text('공급사')), + const ShadTableCell.header(child: Text('비고')), + ]; + final rows = transaction.lines + .map((line) { + return [ + ShadTableCell(child: Text('#${line.lineNo}')), + ShadTableCell(child: Text(_productLabel(line))), + ShadTableCell(child: Text(_quantityFormat.format(line.quantity))), + ShadTableCell(child: Text(line.product.uom?.name ?? '-')), + ShadTableCell(child: Text(line.product.vendor?.name ?? '-')), + ShadTableCell(child: Text(_formatOptional(line.note))), + ]; + }) + .toList(growable: false); + + return ShadCard( + title: Text('품목 ${transaction.lines.length}건', style: theme.textTheme.h4), + child: SizedBox( + height: _tableHeight(rows.length), + child: ShadTable.list( + header: header, + children: rows, + columnSpanExtent: (index) => + FixedTableSpanExtent(_lineColumnWidth(index)), + ), + ), + ); + } + + Widget _buildCustomerCard(ShadThemeData theme, StockTransaction transaction) { + final customers = transaction.customers; + if (customers.isEmpty) { + return ShadCard( + title: Text('연결된 고객', style: theme.textTheme.h4), + child: Padding( + padding: const EdgeInsets.all(16), + child: Text('연결된 고객사가 없습니다.', style: theme.textTheme.muted), + ), + ); + } + final header = [ + const ShadTableCell.header(child: Text('고객사')), + const ShadTableCell.header(child: Text('비고')), + ]; + final rows = customers + .map((customer) { + return [ + ShadTableCell(child: Text(_customerLabel(customer))), + ShadTableCell(child: Text(_formatOptional(customer.note))), + ]; + }) + .toList(growable: false); + return ShadCard( + title: Text('연결된 고객 ${customers.length}곳', style: theme.textTheme.h4), + child: SizedBox( + height: _tableHeight(rows.length), + child: ShadTable.list( + header: header, + children: rows, + columnSpanExtent: (index) => + FixedTableSpanExtent(index == 0 ? 240 : 200), + ), + ), + ); + } + + Widget _infoRow( + ShadThemeData theme, { + required String label, + required String value, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text( + label, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + ), + Expanded(child: Text(value, style: theme.textTheme.p)), + ], + ), + ); + } + + String _formatDate(DateTime? value) { + if (value == null) { + return '-'; + } + return dateFormat.format(value.toLocal()); + } + + String _formatOptional(String? value) { + if (value == null || value.trim().isEmpty) { + return '-'; + } + return value.trim(); + } + + String _customerSummary(List customers) { + if (customers.isEmpty) { + return '-'; + } + final names = customers + .map((customer) => customer.customer.name.trim()) + .where((name) => name.isNotEmpty) + .toSet() + .toList(growable: false); + if (names.isEmpty) { + return '-'; + } + return names.join(', '); + } + + String _customerLabel(StockTransactionCustomer customer) { + final summary = customer.customer; + if (summary.code.isEmpty) { + return summary.name; + } + return '${summary.code} · ${summary.name}'; + } + + String _productLabel(StockTransactionLine line) { + final code = line.product.code.trim(); + final name = line.product.name.trim(); + if (code.isEmpty) { + return name; + } + if (name.isEmpty) { + return code; + } + return '$code · $name'; + } + + double _lineColumnWidth(int index) { + const widths = [80.0, 260.0, 100.0, 80.0, 160.0, 200.0]; + if (index < widths.length) { + return widths[index]; + } + return widths.last; + } + + double _tableHeight(int rowCount) { + const rowHeight = 52.0; + return (rowCount + 1) * rowHeight; + } + + intl.NumberFormat get _quantityFormat => intl.NumberFormat('#,##0'); +} + class _ApprovalHistorySection extends StatelessWidget { const _ApprovalHistorySection({ required this.histories, diff --git a/lib/features/approvals/presentation/pages/approval_page.dart b/lib/features/approvals/presentation/pages/approval_page.dart index 3758cc9..e8d137b 100644 --- a/lib/features/approvals/presentation/pages/approval_page.dart +++ b/lib/features/approvals/presentation/pages/approval_page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart' as intl; @@ -18,6 +19,7 @@ import '../../../../widgets/components/superport_dialog.dart'; import '../../../../widgets/components/superport_table.dart'; import '../../../../widgets/components/superport_pagination_controls.dart'; import '../../../../widgets/components/feature_disabled_placeholder.dart'; +import '../../../auth/application/auth_service.dart'; import '../../domain/entities/approval.dart'; import '../../domain/repositories/approval_repository.dart'; import '../../domain/repositories/approval_template_repository.dart'; @@ -26,6 +28,7 @@ import '../../domain/usecases/list_approval_drafts_use_case.dart'; import '../../domain/usecases/save_approval_draft_use_case.dart'; import '../../../inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; import '../../../inventory/shared/widgets/employee_autocomplete_field.dart'; +import '../../../inventory/transactions/domain/repositories/stock_transaction_repository.dart'; import '../controllers/approval_controller.dart'; import '../dialogs/approval_detail_dialog.dart'; @@ -100,6 +103,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { _controller = ApprovalController( approvalRepository: GetIt.I(), templateRepository: GetIt.I(), + transactionRepository: GetIt.I(), lookupRepository: GetIt.I.isRegistered() ? GetIt.I() : null, @@ -229,6 +233,9 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { _approvalsResourcePath, PermissionAction.edit, ); + final currentUserId = GetIt.I.isRegistered() + ? GetIt.I().session?.user.id + : null; await showApprovalDetailDialog( context: context, @@ -236,6 +243,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { dateFormat: _dateTimeFormat, canPerformStepActions: canPerformStepActions, canApplyTemplate: canApplyTemplate, + currentUserId: currentUserId, ); if (!mounted) { @@ -245,6 +253,31 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { _controller.clearSelection(); } + void _handleRequesterFieldChanged() { + void updateSelection() { + final selectedLabel = _selectedRequester == null + ? '' + : '${_selectedRequester!.name} (${_selectedRequester!.employeeNo})'; + if (_requesterController.text.trim() != selectedLabel) { + _selectedRequester = null; + } + } + + if (!mounted) { + return; + } + final phase = SchedulerBinding.instance.schedulerPhase; + if (phase == SchedulerPhase.persistentCallbacks || + phase == SchedulerPhase.transientCallbacks) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(updateSelection); + }); + return; + } + setState(updateSelection); + } + @override void dispose() { _controller.removeListener(_handleControllerUpdate); @@ -323,16 +356,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { onSuggestionSelected: (suggestion) { setState(() => _selectedRequester = suggestion); }, - onChanged: () { - setState(() { - final selectedLabel = _selectedRequester == null - ? '' - : '${_selectedRequester!.name} (${_selectedRequester!.employeeNo})'; - if (_requesterController.text.trim() != selectedLabel) { - _selectedRequester = null; - } - }); - }, + onChanged: _handleRequesterFieldChanged, ), ), SizedBox( diff --git a/lib/features/auth/domain/entities/auth_permission.dart b/lib/features/auth/domain/entities/auth_permission.dart index aef3562..c1b1c4c 100644 --- a/lib/features/auth/domain/entities/auth_permission.dart +++ b/lib/features/auth/domain/entities/auth_permission.dart @@ -1,5 +1,6 @@ import '../../../../core/permissions/permission_manager.dart'; import '../../../../core/permissions/permission_resources.dart'; +import '../../../../core/permissions/permission_scope_mapper.dart'; /// 로그인 응답에서 내려오는 단일 권한(리소스 + 액션 목록)을 표현한다. class AuthPermission { @@ -23,13 +24,28 @@ class AuthPermission { } actionSet.add(parsed); } - if (actionSet.isEmpty && isScope) { - actionSet.add(PermissionAction.view); + + final mappings = >{}; + if (isScope) { + final scopeMap = PermissionScopeMapper.map(normalized); + if (scopeMap != null && scopeMap.isNotEmpty) { + mappings.addAll(scopeMap); + } } + if (actionSet.isEmpty) { - return >{}; + if (isScope) { + final fallback = {PermissionAction.view}; + mappings.putIfAbsent(normalized, () => {}) + .addAll(fallback); + return mappings; + } + return mappings; } - return {normalized: actionSet}; + + mappings.putIfAbsent(normalized, () => {}) + .addAll(actionSet); + return mappings; } /// 백엔드 권한 문자열을 [PermissionAction]으로 변환한다. diff --git a/lib/features/dashboard/presentation/pages/dashboard_page.dart b/lib/features/dashboard/presentation/pages/dashboard_page.dart index 42e0f08..8520cac 100644 --- a/lib/features/dashboard/presentation/pages/dashboard_page.dart +++ b/lib/features/dashboard/presentation/pages/dashboard_page.dart @@ -7,8 +7,19 @@ import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/network/failure.dart'; +import 'package:superport_v2/core/permissions/permission_manager.dart'; +import 'package:superport_v2/core/permissions/permission_resources.dart'; import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; +import 'package:superport_v2/features/auth/application/auth_service.dart'; import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart'; +import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/get_approval_draft_use_case.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/list_approval_drafts_use_case.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/save_approval_draft_use_case.dart'; +import 'package:superport_v2/features/approvals/presentation/controllers/approval_controller.dart'; +import 'package:superport_v2/features/approvals/presentation/dialogs/approval_detail_dialog.dart'; +import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; +import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/empty_state.dart'; import 'package:superport_v2/widgets/components/feedback.dart'; @@ -446,6 +457,8 @@ class _PendingApprovalCard extends StatelessWidget { return; } final repository = GetIt.I(); + final parentContext = context; + final detailNotifier = ValueNotifier(null); final detailFuture = repository .fetchDetail(approvalId, includeSteps: true, includeHistories: true) .catchError((error) { @@ -462,9 +475,11 @@ class _PendingApprovalCard extends StatelessWidget { debugPrint( '[DashboardPage] 결재 상세 조회 성공: id=${detail.id}, approvalNo=${detail.approvalNo}', ); + detailNotifier.value = detail; return detail; }); if (!context.mounted) { + detailNotifier.dispose(); return; } await SuperportDialog.show( @@ -474,6 +489,37 @@ class _PendingApprovalCard extends StatelessWidget { description: '결재번호 ${approval.approvalNo}', constraints: const BoxConstraints(maxWidth: 760), actions: [ + ValueListenableBuilder( + valueListenable: detailNotifier, + builder: (dialogContext, detail, _) { + return ShadButton.outline( + onPressed: detail == null + ? null + : () async { + final approvalDetailId = detail.id; + if (approvalDetailId == null) { + SuperportToast.error( + parentContext, + '결재 ID가 없어 결재 관리 화면을 열 수 없습니다.', + ); + return; + } + await Navigator.of( + dialogContext, + rootNavigator: true, + ).maybePop(); + if (!parentContext.mounted) { + return; + } + await _openApprovalManagement( + parentContext, + approvalDetailId, + ); + }, + child: const Text('결재 관리'), + ); + }, + ), ShadButton( onPressed: () => Navigator.of(context, rootNavigator: true).maybePop(), @@ -509,6 +555,73 @@ class _PendingApprovalCard extends StatelessWidget { ), ), ); + detailNotifier.dispose(); + } + + Future _openApprovalManagement( + BuildContext context, + int approvalId, + ) async { + final controller = ApprovalController( + approvalRepository: GetIt.I(), + templateRepository: GetIt.I(), + transactionRepository: GetIt.I(), + lookupRepository: GetIt.I.isRegistered() + ? GetIt.I() + : null, + saveDraftUseCase: GetIt.I.isRegistered() + ? GetIt.I() + : null, + getDraftUseCase: GetIt.I.isRegistered() + ? GetIt.I() + : null, + listDraftsUseCase: GetIt.I.isRegistered() + ? GetIt.I() + : null, + ); + final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); + final currentUserId = GetIt.I.isRegistered() + ? GetIt.I().session?.user.id + : null; + + try { + await Future.wait([ + controller.loadActionOptions(), + controller.loadTemplates(), + controller.loadStatusLookups(), + ]); + await controller.selectApproval(approvalId); + if (controller.selected == null) { + final error = controller.errorMessage ?? '결재 상세 정보를 불러오지 못했습니다.'; + if (context.mounted) { + SuperportToast.error(context, error); + } + return; + } + if (!context.mounted) { + return; + } + final permissionScope = PermissionScope.of(context); + final canPerformStepActions = permissionScope.can( + PermissionResources.approvals, + PermissionAction.approve, + ); + final canApplyTemplate = permissionScope.can( + PermissionResources.approvals, + PermissionAction.edit, + ); + + await showApprovalDetailDialog( + context: context, + controller: controller, + dateFormat: dateFormat, + canPerformStepActions: canPerformStepActions, + canApplyTemplate: canApplyTemplate, + currentUserId: currentUserId, + ); + } finally { + controller.dispose(); + } } } diff --git a/lib/features/inventory/summary/presentation/controllers/inventory_detail_controller.dart b/lib/features/inventory/summary/presentation/controllers/inventory_detail_controller.dart index cdf3f14..d1ec012 100644 --- a/lib/features/inventory/summary/presentation/controllers/inventory_detail_controller.dart +++ b/lib/features/inventory/summary/presentation/controllers/inventory_detail_controller.dart @@ -116,6 +116,8 @@ class _InventoryDetailState { this.errorMessage, }); + static const Object _noOverride = Object(); + final InventoryDetailFilter filter; final InventoryDetail? detail; final bool isLoading; @@ -125,13 +127,15 @@ class _InventoryDetailState { InventoryDetailFilter? filter, InventoryDetail? detail, bool? isLoading, - String? errorMessage, + Object? errorMessage = _noOverride, }) { return _InventoryDetailState( filter: filter ?? this.filter, detail: detail ?? this.detail, isLoading: isLoading ?? this.isLoading, - errorMessage: errorMessage ?? this.errorMessage, + errorMessage: identical(errorMessage, _noOverride) + ? this.errorMessage + : errorMessage as String?, ); } } diff --git a/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart b/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart index f39686f..87f689d 100644 --- a/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart +++ b/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart @@ -249,7 +249,6 @@ class StockTransactionApprovalInput { final payload = { 'requested_by_id': requestedById, if (approvalStatusId != null) 'approval_status_id': approvalStatusId, - if (templateId != null) 'template_id': templateId, if (finalApproverId != null) 'final_approver_id': finalApproverId, if (requestedAt != null) 'requested_at': _formatIsoUtc(requestedAt!), if (decidedAt != null) 'decided_at': _formatIsoUtc(decidedAt!), @@ -262,11 +261,19 @@ class StockTransactionApprovalInput { if (trimmedNote != null && trimmedNote.isNotEmpty) 'note': trimmedNote, if (metadata != null && metadata!.isNotEmpty) 'metadata': metadata, }; + final config = {}; + if (templateId != null) { + config['template_id'] = templateId; + } if (steps.isNotEmpty) { - payload['steps'] = steps + config['steps'] = steps .map((item) => _mapApprovalStep(item)) .toList(growable: false); } + if (config.isEmpty) { + throw StateError('결재 템플릿 또는 단계 구성이 필요합니다.'); + } + payload['config'] = config; return payload; } } diff --git a/test/core/permissions/permission_scope_mapper_test.dart b/test/core/permissions/permission_scope_mapper_test.dart new file mode 100644 index 0000000..7aab558 --- /dev/null +++ b/test/core/permissions/permission_scope_mapper_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:superport_v2/core/permissions/permission_manager.dart'; +import 'package:superport_v2/core/permissions/permission_resources.dart'; +import 'package:superport_v2/core/permissions/permission_scope_mapper.dart'; + +void main() { + group('PermissionScopeMapper', () { + test('maps approval scopes to approval resource', () { + final map = PermissionScopeMapper.map('approval.approve'); + + expect(map, isNotNull); + expect( + map![PermissionResources.approvals], + contains(PermissionAction.approve), + ); + }); + + test('maps approvals.* codes to view permissions', () { + final map = PermissionScopeMapper.map('approvals.templates'); + + expect(map, isNotNull); + expect( + map![PermissionResources.approvalTemplates], + contains(PermissionAction.view), + ); + }); + + test('maps dashboard codes to dashboard resource', () { + final map = PermissionScopeMapper.map('dashboard'); + + expect(map, isNotNull); + expect( + map![PermissionResources.dashboard], + contains(PermissionAction.view), + ); + }); + + test('maps inventory transaction codes to stock transactions resource', () { + final map = PermissionScopeMapper.map('inventory.issues'); + + expect(map, isNotNull); + expect( + map![PermissionResources.stockTransactions], + contains(PermissionAction.view), + ); + }); + }); +} diff --git a/test/features/approvals/presentation/dialogs/approval_detail_dialog_test.dart b/test/features/approvals/presentation/dialogs/approval_detail_dialog_test.dart index 6a1bb26..a5302bc 100644 --- a/test/features/approvals/presentation/dialogs/approval_detail_dialog_test.dart +++ b/test/features/approvals/presentation/dialogs/approval_detail_dialog_test.dart @@ -12,6 +12,9 @@ import 'package:superport_v2/features/approvals/domain/repositories/approval_rep import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart'; import 'package:superport_v2/features/approvals/presentation/controllers/approval_controller.dart'; import 'package:superport_v2/features/approvals/presentation/dialogs/approval_detail_dialog.dart'; +import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction.dart'; +import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart'; +import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart'; import 'package:superport_v2/widgets/components/superport_dialog.dart'; import '../../../../helpers/test_app.dart'; @@ -23,17 +26,63 @@ void main() { late _FakeApprovalRepository approvalRepository; late _FakeApprovalTemplateRepository templateRepository; + late _FakeStockTransactionRepository stockRepository; late ApprovalController controller; late Approval sampleApproval; late ApprovalTemplate sampleTemplate; + late StockTransaction sampleTransaction; setUp(() async { approvalRepository = _FakeApprovalRepository(); templateRepository = _FakeApprovalTemplateRepository(); + stockRepository = _FakeStockTransactionRepository(); + sampleTransaction = StockTransaction( + id: 91001, + transactionNo: 'TRX-100', + transactionDate: DateTime(2024, 1, 1, 9), + type: StockTransactionType(id: 1, name: '입고'), + status: StockTransactionStatus(id: 1, name: '초안'), + warehouse: StockTransactionWarehouse(id: 10, code: 'WH-001', name: '1센터'), + createdBy: StockTransactionEmployee( + id: 99, + employeeNo: 'E099', + name: '요청자', + ), + note: '전표 비고', + lines: [ + StockTransactionLine( + id: 1001, + lineNo: 1, + product: StockTransactionProduct( + id: 501, + code: 'P-501', + name: '샘플 제품', + vendor: StockTransactionVendorSummary(id: 7, name: '한빛상사'), + uom: StockTransactionUomSummary(id: 3, name: 'EA'), + ), + quantity: 12, + unitPrice: 0, + note: '라인 비고', + ), + ], + customers: [ + StockTransactionCustomer( + id: 9001, + customer: StockTransactionCustomerSummary( + id: 4001, + code: 'C-4001', + name: '고객A', + ), + ), + ], + expectedReturnDate: DateTime(2024, 1, 10), + ); + stockRepository.detail = sampleTransaction; controller = ApprovalController( approvalRepository: approvalRepository, templateRepository: templateRepository, + transactionRepository: stockRepository, ); final statusInProgress = ApprovalStatus(id: 1, name: '진행중'); @@ -55,8 +104,10 @@ void main() { sampleApproval = Approval( id: 100, approvalNo: 'APP-2024-0100', - transactionNo: 'TRX-100', + transactionId: sampleTransaction.id, + transactionNo: sampleTransaction.transactionNo, status: statusInProgress, + currentStep: step, requester: requester, requestedAt: DateTime(2024, 1, 1, 9), steps: [step], @@ -100,6 +151,7 @@ void main() { await controller.loadTemplates(force: true); await controller.loadActionOptions(force: true); await controller.selectApproval(sampleApproval.id!); + await Future.delayed(const Duration(milliseconds: 10)); expect(controller.templates, isNotEmpty); expect(controller.selected, isNotNull); expect(controller.canProceedSelected, isTrue); @@ -123,6 +175,7 @@ void main() { dateFormat: dateFormat, canPerformStepActions: true, canApplyTemplate: true, + currentUserId: sampleApproval.steps.first.approver.id, ), ); await tester.pumpAndSettle(); @@ -386,3 +439,71 @@ class _FakeApprovalTemplateRepository implements ApprovalTemplateRepository { throw UnimplementedError(); } } + +class _FakeStockTransactionRepository implements StockTransactionRepository { + StockTransaction? detail; + + @override + Future> list({ + StockTransactionListFilter? filter, + }) { + throw UnimplementedError(); + } + + @override + Future fetchDetail( + int id, { + List include = const ['lines', 'customers', 'approval'], + }) async { + final result = detail; + if (result == null) { + throw StateError('transaction detail not set'); + } + return result; + } + + @override + Future create(StockTransactionCreateInput input) { + throw UnimplementedError(); + } + + @override + Future update(int id, StockTransactionUpdateInput input) { + throw UnimplementedError(); + } + + @override + Future delete(int id) { + throw UnimplementedError(); + } + + @override + Future restore(int id) { + throw UnimplementedError(); + } + + @override + Future submit(int id, {String? note}) { + throw UnimplementedError(); + } + + @override + Future complete(int id, {String? note}) { + throw UnimplementedError(); + } + + @override + Future approve(int id, {String? note}) { + throw UnimplementedError(); + } + + @override + Future reject(int id, {String? note}) { + throw UnimplementedError(); + } + + @override + Future cancel(int id, {String? note}) { + throw UnimplementedError(); + } +} diff --git a/test/features/auth/domain/entities/auth_permission_test.dart b/test/features/auth/domain/entities/auth_permission_test.dart index 3e704c7..e6af374 100644 --- a/test/features/auth/domain/entities/auth_permission_test.dart +++ b/test/features/auth/domain/entities/auth_permission_test.dart @@ -1,23 +1,45 @@ import 'package:flutter_test/flutter_test.dart'; - import 'package:superport_v2/core/permissions/permission_manager.dart'; import 'package:superport_v2/core/permissions/permission_resources.dart'; import 'package:superport_v2/features/auth/domain/entities/auth_permission.dart'; void main() { - group('AuthPermission', () { - test('scope 리소스는 actions가 비어도 view 권한을 부여한다', () { + group('AuthPermission.toPermissionMap', () { + test('approval.approve scope grants approval resource approve action', () { const permission = AuthPermission( - resource: 'scope:inventory.view', + resource: 'scope:approval.approve', actions: [], ); final map = permission.toPermissionMap(); - expect(map.length, 1); expect( - map[PermissionResources.inventoryScope], - contains(PermissionAction.view), + map[PermissionResources.approvals], + contains(PermissionAction.approve), + ); + expect(map['scope:approval.approve'], contains(PermissionAction.view)); + }); + + test('approval.manage scope grants manage actions to approval modules', () { + const permission = AuthPermission( + resource: 'scope:approval.manage', + actions: [], + ); + + final map = permission.toPermissionMap(); + + expect( + map[PermissionResources.approvals], + containsAll({ + PermissionAction.view, + PermissionAction.create, + PermissionAction.edit, + PermissionAction.delete, + }), + ); + expect( + map[PermissionResources.approvalTemplates], + contains(PermissionAction.edit), ); }); });