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(
|
final controller = ApprovalController(
|
||||||
approvalRepository: approvalRepository,
|
approvalRepository: approvalRepository,
|
||||||
templateRepository: templateRepository,
|
templateRepository: templateRepository,
|
||||||
|
transactionRepository: stockRepository,
|
||||||
);
|
);
|
||||||
final approveUseCase = ApproveApprovalUseCase(
|
final approveUseCase = ApproveApprovalUseCase(
|
||||||
repository: approvalRepository,
|
repository: approvalRepository,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
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.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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 mappings;
|
||||||
}
|
}
|
||||||
return {normalized: actionSet};
|
|
||||||
|
mappings.putIfAbsent(normalized, () => <PermissionAction>{})
|
||||||
|
.addAll(actionSet);
|
||||||
|
return mappings;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 백엔드 권한 문자열을 [PermissionAction]으로 변환한다.
|
/// 백엔드 권한 문자열을 [PermissionAction]으로 변환한다.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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/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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user