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

@@ -11,6 +11,7 @@ import '../../../../widgets/components/superport_detail_dialog.dart';
import '../../domain/entities/approval.dart';
import '../../domain/entities/approval_template.dart';
import '../../presentation/controllers/approval_controller.dart';
import '../../../inventory/transactions/domain/entities/stock_transaction.dart';
/// 결재 상세 다이얼로그를 표시한다.
///
@@ -22,6 +23,7 @@ Future<void> showApprovalDetailDialog({
required intl.DateFormat dateFormat,
required bool canPerformStepActions,
required bool canApplyTemplate,
required int? currentUserId,
}) {
return showSuperportDialog<void>(
context: context,
@@ -57,6 +59,7 @@ Future<void> showApprovalDetailDialog({
dateFormat: dateFormat,
canPerformStepActions: canPerformStepActions,
canApplyTemplate: canApplyTemplate,
currentUserId: currentUserId,
),
constraints: const BoxConstraints(maxWidth: 880),
barrierDismissible: true,
@@ -70,12 +73,14 @@ class ApprovalDetailDialogView extends StatefulWidget {
required this.dateFormat,
required this.canPerformStepActions,
required this.canApplyTemplate,
required this.currentUserId,
});
final ApprovalController controller;
final intl.DateFormat dateFormat;
final bool canPerformStepActions;
final bool canApplyTemplate;
final int? currentUserId;
@override
State<ApprovalDetailDialogView> createState() =>
@@ -191,6 +196,7 @@ class _ApprovalDetailDialogViewState extends State<ApprovalDetailDialogView> {
final requireNote = type == ApprovalStepActionType.comment;
final noteController = TextEditingController();
String? errorText;
StateSetter? setErrorState;
final confirmed = await showSuperportDialog<String?>(
context: context,
title: _dialogTitle(type),
@@ -198,16 +204,17 @@ class _ApprovalDetailDialogViewState extends State<ApprovalDetailDialogView> {
onSubmit: () {
final note = noteController.text.trim();
if (requireNote && note.isEmpty) {
errorText = '비고를 입력하세요.';
setErrorState?.call(() => errorText = '비고를 입력하세요.');
return;
}
Navigator.of(
context,
rootNavigator: true,
).maybePop(note.isEmpty ? null : note);
).pop(note.isEmpty ? '' : note);
},
body: StatefulBuilder(
builder: (dialogContext, setState) {
setErrorState = setState;
final theme = ShadTheme.of(dialogContext);
final materialTheme = Theme.of(dialogContext);
return Column(
@@ -256,21 +263,20 @@ class _ApprovalDetailDialogViewState extends State<ApprovalDetailDialogView> {
),
actions: [
ShadButton.ghost(
onPressed: () =>
Navigator.of(context, rootNavigator: true).maybePop(),
onPressed: () => Navigator.of(context, rootNavigator: true).pop(),
child: const Text('취소'),
),
ShadButton(
onPressed: () {
final note = noteController.text.trim();
if (requireNote && note.isEmpty) {
errorText = '비고를 입력하세요.';
setErrorState?.call(() => errorText = '비고를 입력하세요.');
return;
}
Navigator.of(
context,
rootNavigator: true,
).maybePop(note.isEmpty ? null : note);
).pop(note.isEmpty ? '' : note);
},
child: Text(_dialogConfirmLabel(type)),
),
@@ -279,7 +285,10 @@ class _ApprovalDetailDialogViewState extends State<ApprovalDetailDialogView> {
return confirmed;
}
List<Widget> _buildSummaryBadges(Approval approval) {
List<Widget> _buildSummaryBadges(
Approval approval,
StockTransaction? transaction,
) {
final badges = <Widget>[ShadBadge(child: Text(approval.status.name))];
badges.add(
approval.isActive
@@ -289,11 +298,16 @@ class _ApprovalDetailDialogViewState extends State<ApprovalDetailDialogView> {
if (approval.isDeleted) {
badges.add(const ShadBadge.destructive(child: Text('삭제됨')));
}
if (transaction != null) {
badges.add(ShadBadge.outline(child: Text(transaction.type.name)));
badges.add(ShadBadge.outline(child: Text(transaction.warehouse.name)));
}
return badges;
}
List<SuperportDetailMetadata> _buildMetadata({
required Approval approval,
required StockTransaction? transaction,
required bool canProceed,
required String? cannotProceedReason,
}) {
@@ -339,6 +353,37 @@ class _ApprovalDetailDialogViewState extends State<ApprovalDetailDialogView> {
? '-'
: approval.note!.trim(),
),
if (transaction != null) ...[
SuperportDetailMetadata.text(
label: '입출고 유형',
value: transaction.type.name,
),
SuperportDetailMetadata.text(
label: '처리일자',
value: _formatDate(transaction.transactionDate),
),
SuperportDetailMetadata.text(
label: '창고',
value: transaction.warehouse.name,
),
SuperportDetailMetadata.text(
label: '작성자',
value:
'${transaction.createdBy.name} (${transaction.createdBy.employeeNo})',
),
SuperportDetailMetadata.text(
label: '품목 수',
value: '${transaction.itemCount}',
),
SuperportDetailMetadata.text(
label: '총 수량',
value: '${transaction.totalQuantity}',
),
SuperportDetailMetadata.text(
label: '예상 반납일',
value: _formatDate(transaction.expectedReturnDate),
),
],
if (!canProceed && cannotProceedReason != null)
SuperportDetailMetadata.text(
label: '진행 제한 사유',
@@ -380,6 +425,10 @@ class _ApprovalDetailDialogViewState extends State<ApprovalDetailDialogView> {
final processingStepId = widget.controller.processingStepId;
final canProceed = widget.controller.canProceedSelected;
final cannotProceedReason = widget.controller.cannotProceedReason;
final transaction = widget.controller.selectedTransaction;
final isTransactionLoading =
widget.controller.isLoadingTransactionDetail;
final transactionError = widget.controller.transactionError;
_ensureTemplateSelectionValid(templates);
final theme = ShadTheme.of(context);
@@ -401,16 +450,35 @@ class _ApprovalDetailDialogViewState extends State<ApprovalDetailDialogView> {
'트랜잭션 ${approval.transactionNo}',
style: theme.textTheme.muted,
),
if (transaction != null) ...[
const SizedBox(height: 2),
Text(
'${transaction.type.name} · ${transaction.warehouse.name}',
style: theme.textTheme.small,
),
],
],
);
final summaryBadges = _buildSummaryBadges(approval);
final summaryBadges = _buildSummaryBadges(approval, transaction);
final metadata = _buildMetadata(
approval: approval,
transaction: transaction,
canProceed: canProceed,
cannotProceedReason: cannotProceedReason,
);
final sections = [
SuperportDetailDialogSection(
id: 'transaction',
label: '전표',
icon: lucide.LucideIcons.clipboardList,
builder: (_) => _ApprovalTransactionSection(
transaction: transaction,
isLoading: isTransactionLoading,
errorMessage: transactionError,
dateFormat: widget.dateFormat,
),
),
SuperportDetailDialogSection(
key: const ValueKey('approval_detail_tab_steps'),
id: 'steps',
@@ -426,12 +494,11 @@ class _ApprovalDetailDialogViewState extends State<ApprovalDetailDialogView> {
selectedTemplateId: _selectedTemplateId,
canApplyTemplate: widget.canApplyTemplate,
canPerformStepActions: widget.canPerformStepActions,
currentUserId: widget.currentUserId,
hasActionOptions: hasActionOptions,
isLoadingActions: isLoadingActions,
isPerformingAction: isPerformingAction,
processingStepId: processingStepId,
canProceed: canProceed,
cannotProceedReason: cannotProceedReason,
onSelectTemplate: (id) => setState(() {
_selectedTemplateId = id;
}),
@@ -479,12 +546,11 @@ class _ApprovalStepSection extends StatelessWidget {
required this.selectedTemplateId,
required this.canApplyTemplate,
required this.canPerformStepActions,
required this.currentUserId,
required this.hasActionOptions,
required this.isLoadingActions,
required this.isPerformingAction,
required this.processingStepId,
required this.canProceed,
required this.cannotProceedReason,
required this.onSelectTemplate,
required this.onApplyTemplate,
required this.onReloadTemplates,
@@ -500,12 +566,11 @@ class _ApprovalStepSection extends StatelessWidget {
final int? selectedTemplateId;
final bool canApplyTemplate;
final bool canPerformStepActions;
final int? currentUserId;
final bool hasActionOptions;
final bool isLoadingActions;
final bool isPerformingAction;
final int? processingStepId;
final bool canProceed;
final String? cannotProceedReason;
final void Function(int?) onSelectTemplate;
final Future<void> Function(int templateId) onApplyTemplate;
final Future<void> Function() onReloadTemplates;
@@ -552,11 +617,17 @@ class _ApprovalStepSection extends StatelessWidget {
isPerformingAction &&
processingStepId != null &&
processingStepId == step.id;
final isAssignedToCurrentUser =
currentUserId != null && step.approver.id == currentUserId;
final currentStep = approval.currentStep;
final isCurrentStep =
currentStep != null &&
currentStep.stepOrder == step.stepOrder;
final disabledReason = _disabledReason(
step,
canPerformStepActions,
canProceed,
cannotProceedReason,
isAssignedToCurrentUser,
isCurrentStep,
);
final enabled = disabledReason == null;
return ShadCard(
@@ -710,14 +781,20 @@ class _ApprovalStepSection extends StatelessWidget {
String? _disabledReason(
ApprovalStep step,
bool canPerform,
bool canProceed,
String? cannotProceedReason,
bool isAssignedToCurrentUser,
bool isCurrentStep,
) {
if (!canPerform) {
return '결재 권한이 없어 단계 행위를 실행할 수 없습니다.';
}
if (!canProceed) {
return cannotProceedReason ?? '현재는 결재 단계를 진행할 수 습니다.';
if (!isAssignedToCurrentUser) {
return '해당 단계 승인자로 배정된 계정만 처리할 수 습니다.';
}
if (step.status.isTerminal) {
return '이미 ${step.status.name} 상태로 처리되었습니다.';
}
if (!isCurrentStep && step.status.isBlockingNext) {
return '아직 이 단계의 처리 순서가 도달하지 않았습니다.';
}
if (step.id == null) {
return '단계 ID가 없어 행위를 수행할 수 없습니다.';
@@ -748,6 +825,283 @@ class _ApprovalStepSection extends StatelessWidget {
}
}
class _ApprovalTransactionSection extends StatelessWidget {
const _ApprovalTransactionSection({
required this.transaction,
required this.isLoading,
required this.errorMessage,
required this.dateFormat,
});
final StockTransaction? transaction;
final bool isLoading;
final String? errorMessage;
final intl.DateFormat dateFormat;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
if (isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (errorMessage != null) {
return ShadCard(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
errorMessage!,
style: theme.textTheme.small.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
),
);
}
final txn = transaction;
if (txn == null) {
return ShadCard(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text('연결된 트랜잭션 정보를 찾을 수 없습니다.', style: theme.textTheme.muted),
),
);
}
final children = <Widget>[
_buildHeaderCard(theme, txn),
const SizedBox(height: 16),
_buildLineCard(theme, txn),
const SizedBox(height: 16),
_buildCustomerCard(theme, txn),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
);
}
Widget _buildHeaderCard(ShadThemeData theme, StockTransaction transaction) {
return ShadCard(
title: Text('전표 정보', style: theme.textTheme.h4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_infoRow(theme, label: '상태', value: transaction.status.name),
_infoRow(
theme,
label: '처리일자',
value: _formatDate(transaction.transactionDate),
),
_infoRow(
theme,
label: '창고',
value:
'${transaction.warehouse.name} '
'(${transaction.warehouse.code})',
),
_infoRow(
theme,
label: '작성자',
value:
'${transaction.createdBy.name} (${transaction.createdBy.employeeNo})',
),
_infoRow(theme, label: '품목 수', value: '${transaction.itemCount}'),
_infoRow(
theme,
label: '총 수량',
value: _quantityFormat.format(transaction.totalQuantity),
),
_infoRow(
theme,
label: '고객사',
value: _customerSummary(transaction.customers),
),
_infoRow(
theme,
label: '예상 반납일',
value: _formatDate(transaction.expectedReturnDate),
),
_infoRow(
theme,
label: '비고',
value: _formatOptional(transaction.note),
),
],
),
);
}
Widget _buildLineCard(ShadThemeData theme, StockTransaction transaction) {
if (transaction.lines.isEmpty) {
return ShadCard(
title: Text('품목', style: theme.textTheme.h4),
child: Padding(
padding: const EdgeInsets.all(16),
child: Text('등록된 품목이 없습니다.', style: theme.textTheme.muted),
),
);
}
final header = <ShadTableCell>[
const ShadTableCell.header(child: Text('라인')),
const ShadTableCell.header(child: Text('제품')),
const ShadTableCell.header(child: Text('수량')),
const ShadTableCell.header(child: Text('단위')),
const ShadTableCell.header(child: Text('공급사')),
const ShadTableCell.header(child: Text('비고')),
];
final rows = transaction.lines
.map((line) {
return <ShadTableCell>[
ShadTableCell(child: Text('#${line.lineNo}')),
ShadTableCell(child: Text(_productLabel(line))),
ShadTableCell(child: Text(_quantityFormat.format(line.quantity))),
ShadTableCell(child: Text(line.product.uom?.name ?? '-')),
ShadTableCell(child: Text(line.product.vendor?.name ?? '-')),
ShadTableCell(child: Text(_formatOptional(line.note))),
];
})
.toList(growable: false);
return ShadCard(
title: Text('품목 ${transaction.lines.length}', style: theme.textTheme.h4),
child: SizedBox(
height: _tableHeight(rows.length),
child: ShadTable.list(
header: header,
children: rows,
columnSpanExtent: (index) =>
FixedTableSpanExtent(_lineColumnWidth(index)),
),
),
);
}
Widget _buildCustomerCard(ShadThemeData theme, StockTransaction transaction) {
final customers = transaction.customers;
if (customers.isEmpty) {
return ShadCard(
title: Text('연결된 고객', style: theme.textTheme.h4),
child: Padding(
padding: const EdgeInsets.all(16),
child: Text('연결된 고객사가 없습니다.', style: theme.textTheme.muted),
),
);
}
final header = <ShadTableCell>[
const ShadTableCell.header(child: Text('고객사')),
const ShadTableCell.header(child: Text('비고')),
];
final rows = customers
.map((customer) {
return <ShadTableCell>[
ShadTableCell(child: Text(_customerLabel(customer))),
ShadTableCell(child: Text(_formatOptional(customer.note))),
];
})
.toList(growable: false);
return ShadCard(
title: Text('연결된 고객 ${customers.length}', style: theme.textTheme.h4),
child: SizedBox(
height: _tableHeight(rows.length),
child: ShadTable.list(
header: header,
children: rows,
columnSpanExtent: (index) =>
FixedTableSpanExtent(index == 0 ? 240 : 200),
),
),
);
}
Widget _infoRow(
ShadThemeData theme, {
required String label,
required String value,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
label,
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.mutedForeground,
),
),
),
Expanded(child: Text(value, style: theme.textTheme.p)),
],
),
);
}
String _formatDate(DateTime? value) {
if (value == null) {
return '-';
}
return dateFormat.format(value.toLocal());
}
String _formatOptional(String? value) {
if (value == null || value.trim().isEmpty) {
return '-';
}
return value.trim();
}
String _customerSummary(List<StockTransactionCustomer> customers) {
if (customers.isEmpty) {
return '-';
}
final names = customers
.map((customer) => customer.customer.name.trim())
.where((name) => name.isNotEmpty)
.toSet()
.toList(growable: false);
if (names.isEmpty) {
return '-';
}
return names.join(', ');
}
String _customerLabel(StockTransactionCustomer customer) {
final summary = customer.customer;
if (summary.code.isEmpty) {
return summary.name;
}
return '${summary.code} · ${summary.name}';
}
String _productLabel(StockTransactionLine line) {
final code = line.product.code.trim();
final name = line.product.name.trim();
if (code.isEmpty) {
return name;
}
if (name.isEmpty) {
return code;
}
return '$code · $name';
}
double _lineColumnWidth(int index) {
const widths = [80.0, 260.0, 100.0, 80.0, 160.0, 200.0];
if (index < widths.length) {
return widths[index];
}
return widths.last;
}
double _tableHeight(int rowCount) {
const rowHeight = 52.0;
return (rowCount + 1) * rowHeight;
}
intl.NumberFormat get _quantityFormat => intl.NumberFormat('#,##0');
}
class _ApprovalHistorySection extends StatelessWidget {
const _ApprovalHistorySection({
required this.histories,