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( final controller = ApprovalController(
approvalRepository: approvalRepository, approvalRepository: approvalRepository,
templateRepository: templateRepository, templateRepository: templateRepository,
transactionRepository: stockRepository,
); );
final approveUseCase = ApproveApprovalUseCase( final approveUseCase = ApproveApprovalUseCase(
repository: approvalRepository, repository: approvalRepository,

View File

@@ -100,7 +100,7 @@ final List<MenuRouteDefinition> menuRouteDefinitions = [
defaultLabel: '재고 현황', defaultLabel: '재고 현황',
icon: lucide.LucideIcons.chartNoAxesColumnIncreasing, icon: lucide.LucideIcons.chartNoAxesColumnIncreasing,
builder: (context, state) => InventorySummaryPage(routeUri: state.uri), builder: (context, state) => InventorySummaryPage(routeUri: state.uri),
defaultOrder: 20, defaultOrder: 11,
extraRequirements: const [ extraRequirements: const [
PermissionRequirement(resource: PermissionResources.inventoryScope), PermissionRequirement(resource: PermissionResources.inventoryScope),
], ],
@@ -112,7 +112,7 @@ final List<MenuRouteDefinition> menuRouteDefinitions = [
defaultLabel: '입고', defaultLabel: '입고',
icon: lucide.LucideIcons.packagePlus, icon: lucide.LucideIcons.packagePlus,
builder: (context, state) => InboundPage(routeUri: state.uri), builder: (context, state) => InboundPage(routeUri: state.uri),
defaultOrder: 21, defaultOrder: 12,
), ),
MenuRouteDefinition( MenuRouteDefinition(
menuCode: 'inventory.issues', menuCode: 'inventory.issues',
@@ -121,7 +121,7 @@ final List<MenuRouteDefinition> menuRouteDefinitions = [
defaultLabel: '출고', defaultLabel: '출고',
icon: lucide.LucideIcons.packageMinus, icon: lucide.LucideIcons.packageMinus,
builder: (context, state) => OutboundPage(routeUri: state.uri), builder: (context, state) => OutboundPage(routeUri: state.uri),
defaultOrder: 22, defaultOrder: 13,
), ),
MenuRouteDefinition( MenuRouteDefinition(
menuCode: 'inventory.rentals', menuCode: 'inventory.rentals',
@@ -129,7 +129,7 @@ final List<MenuRouteDefinition> menuRouteDefinitions = [
defaultLabel: '대여', defaultLabel: '대여',
icon: lucide.LucideIcons.handshake, icon: lucide.LucideIcons.handshake,
builder: (context, state) => RentalPage(routeUri: state.uri), builder: (context, state) => RentalPage(routeUri: state.uri),
defaultOrder: 23, defaultOrder: 14,
), ),
MenuRouteDefinition( MenuRouteDefinition(
menuCode: 'inventory.vendors', 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.dart';
import '../../domain/entities/approval_template.dart'; import '../../domain/entities/approval_template.dart';
import '../../presentation/controllers/approval_controller.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 intl.DateFormat dateFormat,
required bool canPerformStepActions, required bool canPerformStepActions,
required bool canApplyTemplate, required bool canApplyTemplate,
required int? currentUserId,
}) { }) {
return showSuperportDialog<void>( return showSuperportDialog<void>(
context: context, context: context,
@@ -57,6 +59,7 @@ Future<void> showApprovalDetailDialog({
dateFormat: dateFormat, dateFormat: dateFormat,
canPerformStepActions: canPerformStepActions, canPerformStepActions: canPerformStepActions,
canApplyTemplate: canApplyTemplate, canApplyTemplate: canApplyTemplate,
currentUserId: currentUserId,
), ),
constraints: const BoxConstraints(maxWidth: 880), constraints: const BoxConstraints(maxWidth: 880),
barrierDismissible: true, barrierDismissible: true,
@@ -70,12 +73,14 @@ class ApprovalDetailDialogView extends StatefulWidget {
required this.dateFormat, required this.dateFormat,
required this.canPerformStepActions, required this.canPerformStepActions,
required this.canApplyTemplate, required this.canApplyTemplate,
required this.currentUserId,
}); });
final ApprovalController controller; final ApprovalController controller;
final intl.DateFormat dateFormat; final intl.DateFormat dateFormat;
final bool canPerformStepActions; final bool canPerformStepActions;
final bool canApplyTemplate; final bool canApplyTemplate;
final int? currentUserId;
@override @override
State<ApprovalDetailDialogView> createState() => State<ApprovalDetailDialogView> createState() =>
@@ -191,6 +196,7 @@ class _ApprovalDetailDialogViewState extends State<ApprovalDetailDialogView> {
final requireNote = type == ApprovalStepActionType.comment; final requireNote = type == ApprovalStepActionType.comment;
final noteController = TextEditingController(); final noteController = TextEditingController();
String? errorText; String? errorText;
StateSetter? setErrorState;
final confirmed = await showSuperportDialog<String?>( final confirmed = await showSuperportDialog<String?>(
context: context, context: context,
title: _dialogTitle(type), title: _dialogTitle(type),
@@ -198,16 +204,17 @@ class _ApprovalDetailDialogViewState extends State<ApprovalDetailDialogView> {
onSubmit: () { onSubmit: () {
final note = noteController.text.trim(); final note = noteController.text.trim();
if (requireNote && note.isEmpty) { if (requireNote && note.isEmpty) {
errorText = '비고를 입력하세요.'; setErrorState?.call(() => errorText = '비고를 입력하세요.');
return; return;
} }
Navigator.of( Navigator.of(
context, context,
rootNavigator: true, rootNavigator: true,
).maybePop(note.isEmpty ? null : note); ).pop(note.isEmpty ? '' : note);
}, },
body: StatefulBuilder( body: StatefulBuilder(
builder: (dialogContext, setState) { builder: (dialogContext, setState) {
setErrorState = setState;
final theme = ShadTheme.of(dialogContext); final theme = ShadTheme.of(dialogContext);
final materialTheme = Theme.of(dialogContext); final materialTheme = Theme.of(dialogContext);
return Column( return Column(
@@ -256,21 +263,20 @@ class _ApprovalDetailDialogViewState extends State<ApprovalDetailDialogView> {
), ),
actions: [ actions: [
ShadButton.ghost( ShadButton.ghost(
onPressed: () => onPressed: () => Navigator.of(context, rootNavigator: true).pop(),
Navigator.of(context, rootNavigator: true).maybePop(),
child: const Text('취소'), child: const Text('취소'),
), ),
ShadButton( ShadButton(
onPressed: () { onPressed: () {
final note = noteController.text.trim(); final note = noteController.text.trim();
if (requireNote && note.isEmpty) { if (requireNote && note.isEmpty) {
errorText = '비고를 입력하세요.'; setErrorState?.call(() => errorText = '비고를 입력하세요.');
return; return;
} }
Navigator.of( Navigator.of(
context, context,
rootNavigator: true, rootNavigator: true,
).maybePop(note.isEmpty ? null : note); ).pop(note.isEmpty ? '' : note);
}, },
child: Text(_dialogConfirmLabel(type)), child: Text(_dialogConfirmLabel(type)),
), ),
@@ -279,7 +285,10 @@ class _ApprovalDetailDialogViewState extends State<ApprovalDetailDialogView> {
return confirmed; return confirmed;
} }
List<Widget> _buildSummaryBadges(Approval approval) { List<Widget> _buildSummaryBadges(
Approval approval,
StockTransaction? transaction,
) {
final badges = <Widget>[ShadBadge(child: Text(approval.status.name))]; final badges = <Widget>[ShadBadge(child: Text(approval.status.name))];
badges.add( badges.add(
approval.isActive approval.isActive
@@ -289,11 +298,16 @@ class _ApprovalDetailDialogViewState extends State<ApprovalDetailDialogView> {
if (approval.isDeleted) { if (approval.isDeleted) {
badges.add(const ShadBadge.destructive(child: Text('삭제됨'))); 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; return badges;
} }
List<SuperportDetailMetadata> _buildMetadata({ List<SuperportDetailMetadata> _buildMetadata({
required Approval approval, required Approval approval,
required StockTransaction? transaction,
required bool canProceed, required bool canProceed,
required String? cannotProceedReason, required String? cannotProceedReason,
}) { }) {
@@ -339,6 +353,37 @@ class _ApprovalDetailDialogViewState extends State<ApprovalDetailDialogView> {
? '-' ? '-'
: approval.note!.trim(), : 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) if (!canProceed && cannotProceedReason != null)
SuperportDetailMetadata.text( SuperportDetailMetadata.text(
label: '진행 제한 사유', label: '진행 제한 사유',
@@ -380,6 +425,10 @@ class _ApprovalDetailDialogViewState extends State<ApprovalDetailDialogView> {
final processingStepId = widget.controller.processingStepId; final processingStepId = widget.controller.processingStepId;
final canProceed = widget.controller.canProceedSelected; final canProceed = widget.controller.canProceedSelected;
final cannotProceedReason = widget.controller.cannotProceedReason; final cannotProceedReason = widget.controller.cannotProceedReason;
final transaction = widget.controller.selectedTransaction;
final isTransactionLoading =
widget.controller.isLoadingTransactionDetail;
final transactionError = widget.controller.transactionError;
_ensureTemplateSelectionValid(templates); _ensureTemplateSelectionValid(templates);
final theme = ShadTheme.of(context); final theme = ShadTheme.of(context);
@@ -401,16 +450,35 @@ class _ApprovalDetailDialogViewState extends State<ApprovalDetailDialogView> {
'트랜잭션 ${approval.transactionNo}', '트랜잭션 ${approval.transactionNo}',
style: theme.textTheme.muted, 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( final metadata = _buildMetadata(
approval: approval, approval: approval,
transaction: transaction,
canProceed: canProceed, canProceed: canProceed,
cannotProceedReason: cannotProceedReason, cannotProceedReason: cannotProceedReason,
); );
final sections = [ final sections = [
SuperportDetailDialogSection(
id: 'transaction',
label: '전표',
icon: lucide.LucideIcons.clipboardList,
builder: (_) => _ApprovalTransactionSection(
transaction: transaction,
isLoading: isTransactionLoading,
errorMessage: transactionError,
dateFormat: widget.dateFormat,
),
),
SuperportDetailDialogSection( SuperportDetailDialogSection(
key: const ValueKey('approval_detail_tab_steps'), key: const ValueKey('approval_detail_tab_steps'),
id: 'steps', id: 'steps',
@@ -426,12 +494,11 @@ class _ApprovalDetailDialogViewState extends State<ApprovalDetailDialogView> {
selectedTemplateId: _selectedTemplateId, selectedTemplateId: _selectedTemplateId,
canApplyTemplate: widget.canApplyTemplate, canApplyTemplate: widget.canApplyTemplate,
canPerformStepActions: widget.canPerformStepActions, canPerformStepActions: widget.canPerformStepActions,
currentUserId: widget.currentUserId,
hasActionOptions: hasActionOptions, hasActionOptions: hasActionOptions,
isLoadingActions: isLoadingActions, isLoadingActions: isLoadingActions,
isPerformingAction: isPerformingAction, isPerformingAction: isPerformingAction,
processingStepId: processingStepId, processingStepId: processingStepId,
canProceed: canProceed,
cannotProceedReason: cannotProceedReason,
onSelectTemplate: (id) => setState(() { onSelectTemplate: (id) => setState(() {
_selectedTemplateId = id; _selectedTemplateId = id;
}), }),
@@ -479,12 +546,11 @@ class _ApprovalStepSection extends StatelessWidget {
required this.selectedTemplateId, required this.selectedTemplateId,
required this.canApplyTemplate, required this.canApplyTemplate,
required this.canPerformStepActions, required this.canPerformStepActions,
required this.currentUserId,
required this.hasActionOptions, required this.hasActionOptions,
required this.isLoadingActions, required this.isLoadingActions,
required this.isPerformingAction, required this.isPerformingAction,
required this.processingStepId, required this.processingStepId,
required this.canProceed,
required this.cannotProceedReason,
required this.onSelectTemplate, required this.onSelectTemplate,
required this.onApplyTemplate, required this.onApplyTemplate,
required this.onReloadTemplates, required this.onReloadTemplates,
@@ -500,12 +566,11 @@ class _ApprovalStepSection extends StatelessWidget {
final int? selectedTemplateId; final int? selectedTemplateId;
final bool canApplyTemplate; final bool canApplyTemplate;
final bool canPerformStepActions; final bool canPerformStepActions;
final int? currentUserId;
final bool hasActionOptions; final bool hasActionOptions;
final bool isLoadingActions; final bool isLoadingActions;
final bool isPerformingAction; final bool isPerformingAction;
final int? processingStepId; final int? processingStepId;
final bool canProceed;
final String? cannotProceedReason;
final void Function(int?) onSelectTemplate; final void Function(int?) onSelectTemplate;
final Future<void> Function(int templateId) onApplyTemplate; final Future<void> Function(int templateId) onApplyTemplate;
final Future<void> Function() onReloadTemplates; final Future<void> Function() onReloadTemplates;
@@ -552,11 +617,17 @@ class _ApprovalStepSection extends StatelessWidget {
isPerformingAction && isPerformingAction &&
processingStepId != null && processingStepId != null &&
processingStepId == step.id; 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( final disabledReason = _disabledReason(
step, step,
canPerformStepActions, canPerformStepActions,
canProceed, isAssignedToCurrentUser,
cannotProceedReason, isCurrentStep,
); );
final enabled = disabledReason == null; final enabled = disabledReason == null;
return ShadCard( return ShadCard(
@@ -710,14 +781,20 @@ class _ApprovalStepSection extends StatelessWidget {
String? _disabledReason( String? _disabledReason(
ApprovalStep step, ApprovalStep step,
bool canPerform, bool canPerform,
bool canProceed, bool isAssignedToCurrentUser,
String? cannotProceedReason, bool isCurrentStep,
) { ) {
if (!canPerform) { if (!canPerform) {
return '결재 권한이 없어 단계 행위를 실행할 수 없습니다.'; return '결재 권한이 없어 단계 행위를 실행할 수 없습니다.';
} }
if (!canProceed) { if (!isAssignedToCurrentUser) {
return cannotProceedReason ?? '현재는 결재 단계를 진행할 수 습니다.'; return '해당 단계 승인자로 배정된 계정만 처리할 수 습니다.';
}
if (step.status.isTerminal) {
return '이미 ${step.status.name} 상태로 처리되었습니다.';
}
if (!isCurrentStep && step.status.isBlockingNext) {
return '아직 이 단계의 처리 순서가 도달하지 않았습니다.';
} }
if (step.id == null) { if (step.id == null) {
return '단계 ID가 없어 행위를 수행할 수 없습니다.'; 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 { class _ApprovalHistorySection extends StatelessWidget {
const _ApprovalHistorySection({ const _ApprovalHistorySection({
required this.histories, required this.histories,

View File

@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart' as intl; 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_table.dart';
import '../../../../widgets/components/superport_pagination_controls.dart'; import '../../../../widgets/components/superport_pagination_controls.dart';
import '../../../../widgets/components/feature_disabled_placeholder.dart'; import '../../../../widgets/components/feature_disabled_placeholder.dart';
import '../../../auth/application/auth_service.dart';
import '../../domain/entities/approval.dart'; import '../../domain/entities/approval.dart';
import '../../domain/repositories/approval_repository.dart'; import '../../domain/repositories/approval_repository.dart';
import '../../domain/repositories/approval_template_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 '../../domain/usecases/save_approval_draft_use_case.dart';
import '../../../inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; import '../../../inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
import '../../../inventory/shared/widgets/employee_autocomplete_field.dart'; import '../../../inventory/shared/widgets/employee_autocomplete_field.dart';
import '../../../inventory/transactions/domain/repositories/stock_transaction_repository.dart';
import '../controllers/approval_controller.dart'; import '../controllers/approval_controller.dart';
import '../dialogs/approval_detail_dialog.dart'; import '../dialogs/approval_detail_dialog.dart';
@@ -100,6 +103,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
_controller = ApprovalController( _controller = ApprovalController(
approvalRepository: GetIt.I<ApprovalRepository>(), approvalRepository: GetIt.I<ApprovalRepository>(),
templateRepository: GetIt.I<ApprovalTemplateRepository>(), templateRepository: GetIt.I<ApprovalTemplateRepository>(),
transactionRepository: GetIt.I<StockTransactionRepository>(),
lookupRepository: GetIt.I.isRegistered<InventoryLookupRepository>() lookupRepository: GetIt.I.isRegistered<InventoryLookupRepository>()
? GetIt.I<InventoryLookupRepository>() ? GetIt.I<InventoryLookupRepository>()
: null, : null,
@@ -229,6 +233,9 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
_approvalsResourcePath, _approvalsResourcePath,
PermissionAction.edit, PermissionAction.edit,
); );
final currentUserId = GetIt.I.isRegistered<AuthService>()
? GetIt.I<AuthService>().session?.user.id
: null;
await showApprovalDetailDialog( await showApprovalDetailDialog(
context: context, context: context,
@@ -236,6 +243,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
dateFormat: _dateTimeFormat, dateFormat: _dateTimeFormat,
canPerformStepActions: canPerformStepActions, canPerformStepActions: canPerformStepActions,
canApplyTemplate: canApplyTemplate, canApplyTemplate: canApplyTemplate,
currentUserId: currentUserId,
); );
if (!mounted) { if (!mounted) {
@@ -245,6 +253,31 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
_controller.clearSelection(); _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 @override
void dispose() { void dispose() {
_controller.removeListener(_handleControllerUpdate); _controller.removeListener(_handleControllerUpdate);
@@ -323,16 +356,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
onSuggestionSelected: (suggestion) { onSuggestionSelected: (suggestion) {
setState(() => _selectedRequester = suggestion); setState(() => _selectedRequester = suggestion);
}, },
onChanged: () { onChanged: _handleRequesterFieldChanged,
setState(() {
final selectedLabel = _selectedRequester == null
? ''
: '${_selectedRequester!.name} (${_selectedRequester!.employeeNo})';
if (_requesterController.text.trim() != selectedLabel) {
_selectedRequester = null;
}
});
},
), ),
), ),
SizedBox( SizedBox(

View File

@@ -1,5 +1,6 @@
import '../../../../core/permissions/permission_manager.dart'; import '../../../../core/permissions/permission_manager.dart';
import '../../../../core/permissions/permission_resources.dart'; import '../../../../core/permissions/permission_resources.dart';
import '../../../../core/permissions/permission_scope_mapper.dart';
/// 로그인 응답에서 내려오는 단일 권한(리소스 + 액션 목록)을 표현한다. /// 로그인 응답에서 내려오는 단일 권한(리소스 + 액션 목록)을 표현한다.
class AuthPermission { class AuthPermission {
@@ -23,13 +24,28 @@ class AuthPermission {
} }
actionSet.add(parsed); 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) { if (actionSet.isEmpty) {
return <String, Set<PermissionAction>>{}; if (isScope) {
final fallback = {PermissionAction.view};
mappings.putIfAbsent(normalized, () => <PermissionAction>{})
.addAll(fallback);
return mappings;
} }
return {normalized: actionSet}; return mappings;
}
mappings.putIfAbsent(normalized, () => <PermissionAction>{})
.addAll(actionSet);
return mappings;
} }
/// 백엔드 권한 문자열을 [PermissionAction]으로 변환한다. /// 백엔드 권한 문자열을 [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:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/core/network/failure.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/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_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/app_layout.dart';
import 'package:superport_v2/widgets/components/empty_state.dart'; import 'package:superport_v2/widgets/components/empty_state.dart';
import 'package:superport_v2/widgets/components/feedback.dart'; import 'package:superport_v2/widgets/components/feedback.dart';
@@ -446,6 +457,8 @@ class _PendingApprovalCard extends StatelessWidget {
return; return;
} }
final repository = GetIt.I<ApprovalRepository>(); final repository = GetIt.I<ApprovalRepository>();
final parentContext = context;
final detailNotifier = ValueNotifier<Approval?>(null);
final detailFuture = repository final detailFuture = repository
.fetchDetail(approvalId, includeSteps: true, includeHistories: true) .fetchDetail(approvalId, includeSteps: true, includeHistories: true)
.catchError((error) { .catchError((error) {
@@ -462,9 +475,11 @@ class _PendingApprovalCard extends StatelessWidget {
debugPrint( debugPrint(
'[DashboardPage] 결재 상세 조회 성공: id=${detail.id}, approvalNo=${detail.approvalNo}', '[DashboardPage] 결재 상세 조회 성공: id=${detail.id}, approvalNo=${detail.approvalNo}',
); );
detailNotifier.value = detail;
return detail; return detail;
}); });
if (!context.mounted) { if (!context.mounted) {
detailNotifier.dispose();
return; return;
} }
await SuperportDialog.show<void>( await SuperportDialog.show<void>(
@@ -474,6 +489,37 @@ class _PendingApprovalCard extends StatelessWidget {
description: '결재번호 ${approval.approvalNo}', description: '결재번호 ${approval.approvalNo}',
constraints: const BoxConstraints(maxWidth: 760), constraints: const BoxConstraints(maxWidth: 760),
actions: [ 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( ShadButton(
onPressed: () => onPressed: () =>
Navigator.of(context, rootNavigator: true).maybePop(), 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, this.errorMessage,
}); });
static const Object _noOverride = Object();
final InventoryDetailFilter filter; final InventoryDetailFilter filter;
final InventoryDetail? detail; final InventoryDetail? detail;
final bool isLoading; final bool isLoading;
@@ -125,13 +127,15 @@ class _InventoryDetailState {
InventoryDetailFilter? filter, InventoryDetailFilter? filter,
InventoryDetail? detail, InventoryDetail? detail,
bool? isLoading, bool? isLoading,
String? errorMessage, Object? errorMessage = _noOverride,
}) { }) {
return _InventoryDetailState( return _InventoryDetailState(
filter: filter ?? this.filter, filter: filter ?? this.filter,
detail: detail ?? this.detail, detail: detail ?? this.detail,
isLoading: isLoading ?? this.isLoading, 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>{ final payload = <String, dynamic>{
'requested_by_id': requestedById, 'requested_by_id': requestedById,
if (approvalStatusId != null) 'approval_status_id': approvalStatusId, if (approvalStatusId != null) 'approval_status_id': approvalStatusId,
if (templateId != null) 'template_id': templateId,
if (finalApproverId != null) 'final_approver_id': finalApproverId, if (finalApproverId != null) 'final_approver_id': finalApproverId,
if (requestedAt != null) 'requested_at': _formatIsoUtc(requestedAt!), if (requestedAt != null) 'requested_at': _formatIsoUtc(requestedAt!),
if (decidedAt != null) 'decided_at': _formatIsoUtc(decidedAt!), if (decidedAt != null) 'decided_at': _formatIsoUtc(decidedAt!),
@@ -262,11 +261,19 @@ class StockTransactionApprovalInput {
if (trimmedNote != null && trimmedNote.isNotEmpty) 'note': trimmedNote, if (trimmedNote != null && trimmedNote.isNotEmpty) 'note': trimmedNote,
if (metadata != null && metadata!.isNotEmpty) 'metadata': metadata, if (metadata != null && metadata!.isNotEmpty) 'metadata': metadata,
}; };
final config = <String, dynamic>{};
if (templateId != null) {
config['template_id'] = templateId;
}
if (steps.isNotEmpty) { if (steps.isNotEmpty) {
payload['steps'] = steps config['steps'] = steps
.map((item) => _mapApprovalStep(item)) .map((item) => _mapApprovalStep(item))
.toList(growable: false); .toList(growable: false);
} }
if (config.isEmpty) {
throw StateError('결재 템플릿 또는 단계 구성이 필요합니다.');
}
payload['config'] = config;
return payload; 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/domain/repositories/approval_template_repository.dart';
import 'package:superport_v2/features/approvals/presentation/controllers/approval_controller.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/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 'package:superport_v2/widgets/components/superport_dialog.dart';
import '../../../../helpers/test_app.dart'; import '../../../../helpers/test_app.dart';
@@ -23,17 +26,63 @@ void main() {
late _FakeApprovalRepository approvalRepository; late _FakeApprovalRepository approvalRepository;
late _FakeApprovalTemplateRepository templateRepository; late _FakeApprovalTemplateRepository templateRepository;
late _FakeStockTransactionRepository stockRepository;
late ApprovalController controller; late ApprovalController controller;
late Approval sampleApproval; late Approval sampleApproval;
late ApprovalTemplate sampleTemplate; late ApprovalTemplate sampleTemplate;
late StockTransaction sampleTransaction;
setUp(() async { setUp(() async {
approvalRepository = _FakeApprovalRepository(); approvalRepository = _FakeApprovalRepository();
templateRepository = _FakeApprovalTemplateRepository(); 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( controller = ApprovalController(
approvalRepository: approvalRepository, approvalRepository: approvalRepository,
templateRepository: templateRepository, templateRepository: templateRepository,
transactionRepository: stockRepository,
); );
final statusInProgress = ApprovalStatus(id: 1, name: '진행중'); final statusInProgress = ApprovalStatus(id: 1, name: '진행중');
@@ -55,8 +104,10 @@ void main() {
sampleApproval = Approval( sampleApproval = Approval(
id: 100, id: 100,
approvalNo: 'APP-2024-0100', approvalNo: 'APP-2024-0100',
transactionNo: 'TRX-100', transactionId: sampleTransaction.id,
transactionNo: sampleTransaction.transactionNo,
status: statusInProgress, status: statusInProgress,
currentStep: step,
requester: requester, requester: requester,
requestedAt: DateTime(2024, 1, 1, 9), requestedAt: DateTime(2024, 1, 1, 9),
steps: [step], steps: [step],
@@ -100,6 +151,7 @@ void main() {
await controller.loadTemplates(force: true); await controller.loadTemplates(force: true);
await controller.loadActionOptions(force: true); await controller.loadActionOptions(force: true);
await controller.selectApproval(sampleApproval.id!); await controller.selectApproval(sampleApproval.id!);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(controller.templates, isNotEmpty); expect(controller.templates, isNotEmpty);
expect(controller.selected, isNotNull); expect(controller.selected, isNotNull);
expect(controller.canProceedSelected, isTrue); expect(controller.canProceedSelected, isTrue);
@@ -123,6 +175,7 @@ void main() {
dateFormat: dateFormat, dateFormat: dateFormat,
canPerformStepActions: true, canPerformStepActions: true,
canApplyTemplate: true, canApplyTemplate: true,
currentUserId: sampleApproval.steps.first.approver.id,
), ),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -386,3 +439,71 @@ class _FakeApprovalTemplateRepository implements ApprovalTemplateRepository {
throw UnimplementedError(); 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:flutter_test/flutter_test.dart';
import 'package:superport_v2/core/permissions/permission_manager.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_resources.dart';
import 'package:superport_v2/features/auth/domain/entities/auth_permission.dart'; import 'package:superport_v2/features/auth/domain/entities/auth_permission.dart';
void main() { void main() {
group('AuthPermission', () { group('AuthPermission.toPermissionMap', () {
test('scope 리소스는 actions가 비어도 view 권한을 부여한다', () { test('approval.approve scope grants approval resource approve action', () {
const permission = AuthPermission( const permission = AuthPermission(
resource: 'scope:inventory.view', resource: 'scope:approval.approve',
actions: [], actions: [],
); );
final map = permission.toPermissionMap(); final map = permission.toPermissionMap();
expect(map.length, 1);
expect( expect(
map[PermissionResources.inventoryScope], map[PermissionResources.approvals],
contains(PermissionAction.view), 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),
); );
}); });
}); });