feat(approvals): 결재 상세 전표 연동과 스코프 권한 매핑 확장

- 결재 상세 다이얼로그에 전표 요약·라인·고객 섹션을 추가하고 현재 사용자 단계 강조 및 비고 입력 검증을 개선함

- 대시보드·결재 목록에서 전표 리포지토리와 AuthService를 주입해 상세 진입과 결재 관리 이동 버튼을 제공함

- StockTransactionApprovalInput이 template/steps를 config 노드로 직렬화하도록 변경하고 통합 테스트를 갱신함

- scope 권한 문자열을 리소스권으로 변환하는 PermissionScopeMapper와 단위 테스트를 추가하고 AuthPermission을 연동함

- 재고 메뉴 정렬, 상세 컨트롤러 오류 리셋, 요청자 자동완성 상태 동기화 등 주변 UI 버그를 수정하고 테스트를 보강함
This commit is contained in:
JiWoong Sul
2025-11-14 01:57:02 +09:00
parent e3cf068bf8
commit 6d09e72142
12 changed files with 857 additions and 50 deletions

View File

@@ -72,6 +72,7 @@ void main() {
final controller = ApprovalController(
approvalRepository: approvalRepository,
templateRepository: templateRepository,
transactionRepository: stockRepository,
);
final approveUseCase = ApproveApprovalUseCase(
repository: approvalRepository,

View File

@@ -100,7 +100,7 @@ final List<MenuRouteDefinition> 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<MenuRouteDefinition> 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<MenuRouteDefinition> 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<MenuRouteDefinition> menuRouteDefinitions = [
defaultLabel: '대여',
icon: lucide.LucideIcons.handshake,
builder: (context, state) => RentalPage(routeUri: state.uri),
defaultOrder: 23,
defaultOrder: 14,
),
MenuRouteDefinition(
menuCode: 'inventory.vendors',

View File

@@ -0,0 +1,97 @@
import 'permission_manager.dart';
import 'permission_resources.dart';
/// 서버가 내려주는 scope 권한 코드를 실사용 리소스 권한으로 변환한다.
class PermissionScopeMapper {
const PermissionScopeMapper._();
/// scope:<code> 형식의 권한에서 [PermissionManager]가 이해할 수 있는 리소스 맵을 생성한다.
static Map<String, Set<PermissionAction>>? 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 = <String, Set<PermissionAction>>{};
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<String, Map<String, Set<PermissionAction>>> _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},
},
};
}

View File

@@ -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<void> showApprovalDetailDialog({
required intl.DateFormat dateFormat,
required bool canPerformStepActions,
required bool canApplyTemplate,
required int? currentUserId,
}) {
return showSuperportDialog<void>(
context: context,
@@ -57,6 +59,7 @@ Future<void> 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<ApprovalDetailDialogView> createState() =>
@@ -191,6 +196,7 @@ class _ApprovalDetailDialogViewState extends State<ApprovalDetailDialogView> {
final requireNote = type == ApprovalStepActionType.comment;
final noteController = TextEditingController();
String? errorText;
StateSetter? setErrorState;
final confirmed = await showSuperportDialog<String?>(
context: context,
title: _dialogTitle(type),
@@ -198,16 +204,17 @@ class _ApprovalDetailDialogViewState extends State<ApprovalDetailDialogView> {
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<ApprovalDetailDialogView> {
),
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<ApprovalDetailDialogView> {
return confirmed;
}
List<Widget> _buildSummaryBadges(Approval approval) {
List<Widget> _buildSummaryBadges(
Approval approval,
StockTransaction? transaction,
) {
final badges = <Widget>[ShadBadge(child: Text(approval.status.name))];
badges.add(
approval.isActive
@@ -289,11 +298,16 @@ class _ApprovalDetailDialogViewState extends State<ApprovalDetailDialogView> {
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<SuperportDetailMetadata> _buildMetadata({
required Approval approval,
required StockTransaction? transaction,
required bool canProceed,
required String? cannotProceedReason,
}) {
@@ -339,6 +353,37 @@ class _ApprovalDetailDialogViewState extends State<ApprovalDetailDialogView> {
? '-'
: 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<ApprovalDetailDialogView> {
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<ApprovalDetailDialogView> {
'트랜잭션 ${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<ApprovalDetailDialogView> {
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<void> Function(int templateId) onApplyTemplate;
final Future<void> 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 = <Widget>[
_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 = <ShadTableCell>[
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>[
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 = <ShadTableCell>[
const ShadTableCell.header(child: Text('고객사')),
const ShadTableCell.header(child: Text('비고')),
];
final rows = customers
.map((customer) {
return <ShadTableCell>[
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<StockTransactionCustomer> 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,

View File

@@ -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<ApprovalRepository>(),
templateRepository: GetIt.I<ApprovalTemplateRepository>(),
transactionRepository: GetIt.I<StockTransactionRepository>(),
lookupRepository: GetIt.I.isRegistered<InventoryLookupRepository>()
? GetIt.I<InventoryLookupRepository>()
: null,
@@ -229,6 +233,9 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
_approvalsResourcePath,
PermissionAction.edit,
);
final currentUserId = GetIt.I.isRegistered<AuthService>()
? GetIt.I<AuthService>().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(

View File

@@ -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 = <String, Set<PermissionAction>>{};
if (isScope) {
final scopeMap = PermissionScopeMapper.map(normalized);
if (scopeMap != null && scopeMap.isNotEmpty) {
mappings.addAll(scopeMap);
}
}
if (actionSet.isEmpty) {
return <String, Set<PermissionAction>>{};
if (isScope) {
final fallback = {PermissionAction.view};
mappings.putIfAbsent(normalized, () => <PermissionAction>{})
.addAll(fallback);
return mappings;
}
return mappings;
}
return {normalized: actionSet};
mappings.putIfAbsent(normalized, () => <PermissionAction>{})
.addAll(actionSet);
return mappings;
}
/// 백엔드 권한 문자열을 [PermissionAction]으로 변환한다.

View File

@@ -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<ApprovalRepository>();
final parentContext = context;
final detailNotifier = ValueNotifier<Approval?>(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<void>(
@@ -474,6 +489,37 @@ class _PendingApprovalCard extends StatelessWidget {
description: '결재번호 ${approval.approvalNo}',
constraints: const BoxConstraints(maxWidth: 760),
actions: [
ValueListenableBuilder<Approval?>(
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<void> _openApprovalManagement(
BuildContext context,
int approvalId,
) async {
final controller = ApprovalController(
approvalRepository: GetIt.I<ApprovalRepository>(),
templateRepository: GetIt.I<ApprovalTemplateRepository>(),
transactionRepository: GetIt.I<StockTransactionRepository>(),
lookupRepository: GetIt.I.isRegistered<InventoryLookupRepository>()
? GetIt.I<InventoryLookupRepository>()
: null,
saveDraftUseCase: GetIt.I.isRegistered<SaveApprovalDraftUseCase>()
? GetIt.I<SaveApprovalDraftUseCase>()
: null,
getDraftUseCase: GetIt.I.isRegistered<GetApprovalDraftUseCase>()
? GetIt.I<GetApprovalDraftUseCase>()
: null,
listDraftsUseCase: GetIt.I.isRegistered<ListApprovalDraftsUseCase>()
? GetIt.I<ListApprovalDraftsUseCase>()
: null,
);
final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
final currentUserId = GetIt.I.isRegistered<AuthService>()
? GetIt.I<AuthService>().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();
}
}
}

View File

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

View File

@@ -249,7 +249,6 @@ class StockTransactionApprovalInput {
final payload = <String, dynamic>{
'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 = <String, dynamic>{};
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;
}
}

View File

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

View File

@@ -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<void>.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<PaginatedResult<StockTransaction>> list({
StockTransactionListFilter? filter,
}) {
throw UnimplementedError();
}
@override
Future<StockTransaction> fetchDetail(
int id, {
List<String> include = const ['lines', 'customers', 'approval'],
}) async {
final result = detail;
if (result == null) {
throw StateError('transaction detail not set');
}
return result;
}
@override
Future<StockTransaction> create(StockTransactionCreateInput input) {
throw UnimplementedError();
}
@override
Future<StockTransaction> update(int id, StockTransactionUpdateInput input) {
throw UnimplementedError();
}
@override
Future<void> delete(int id) {
throw UnimplementedError();
}
@override
Future<StockTransaction> restore(int id) {
throw UnimplementedError();
}
@override
Future<StockTransaction> submit(int id, {String? note}) {
throw UnimplementedError();
}
@override
Future<StockTransaction> complete(int id, {String? note}) {
throw UnimplementedError();
}
@override
Future<StockTransaction> approve(int id, {String? note}) {
throw UnimplementedError();
}
@override
Future<StockTransaction> reject(int id, {String? note}) {
throw UnimplementedError();
}
@override
Future<StockTransaction> cancel(int id, {String? note}) {
throw UnimplementedError();
}
}

View File

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