feat(approvals): 결재 상세 전표 연동과 스코프 권한 매핑 확장
- 결재 상세 다이얼로그에 전표 요약·라인·고객 섹션을 추가하고 현재 사용자 단계 강조 및 비고 입력 검증을 개선함 - 대시보드·결재 목록에서 전표 리포지토리와 AuthService를 주입해 상세 진입과 결재 관리 이동 버튼을 제공함 - StockTransactionApprovalInput이 template/steps를 config 노드로 직렬화하도록 변경하고 통합 테스트를 갱신함 - scope 권한 문자열을 리소스권으로 변환하는 PermissionScopeMapper와 단위 테스트를 추가하고 AuthPermission을 연동함 - 재고 메뉴 정렬, 상세 컨트롤러 오류 리셋, 요청자 자동완성 상태 동기화 등 주변 UI 버그를 수정하고 테스트를 보강함
This commit is contained in:
@@ -72,6 +72,7 @@ void main() {
|
||||
final controller = ApprovalController(
|
||||
approvalRepository: approvalRepository,
|
||||
templateRepository: templateRepository,
|
||||
transactionRepository: stockRepository,
|
||||
);
|
||||
final approveUseCase = ApproveApprovalUseCase(
|
||||
repository: approvalRepository,
|
||||
|
||||
@@ -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',
|
||||
|
||||
97
lib/core/permissions/permission_scope_mapper.dart
Normal file
97
lib/core/permissions/permission_scope_mapper.dart
Normal 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},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]으로 변환한다.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
48
test/core/permissions/permission_scope_mapper_test.dart
Normal file
48
test/core/permissions/permission_scope_mapper_test.dart
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user