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