- .env.development.example과 lib/core/config/environment.dart, lib/core/permissions/permission_manager.dart에서 PERMISSION__ 폴백을 view 전용으로 좁히고 기본 정책을 명시적으로 거부하도록 재정비했다 - lib/core/navigation/*, lib/core/routing/app_router.dart, lib/widgets/app_shell.dart, lib/main.dart에서 메뉴 매니페스트·카탈로그를 도입해 /menus 응답을 캐싱하고 라우터·사이드바·Breadcrumb가 동일 menu_code/route_path를 쓰도록 리팩터링했다 - lib/core/permissions/permission_resources.dart와 그룹 권한/메뉴 마스터 모듈을 menu_code 기반 CRUD 및 Catalog 경로 정합성 검사로 전환하고 PermissionSynchronizer·PermissionBootstrapper를 확장했다 - test/helpers/test_permissions.dart, test/widgets/app_shell_test.dart 등 신규 구조를 반영하는 테스트·골든과 doc/frontend_menu_permission_tasks.md 문서를 보강했다
863 lines
33 KiB
Dart
863 lines
33 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:get_it/get_it.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:intl/intl.dart' as intl;
|
|
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
|
|
|
import '../../../../core/config/environment.dart';
|
|
import '../../../../core/navigation/route_paths.dart';
|
|
import '../../../../core/permissions/permission_manager.dart';
|
|
import '../../../../core/permissions/permission_resources.dart';
|
|
import '../../../../widgets/app_layout.dart';
|
|
import '../../../../widgets/components/feedback.dart';
|
|
import '../../../../widgets/components/filter_bar.dart';
|
|
import '../../../../widgets/components/superport_dialog.dart';
|
|
import '../../../../widgets/components/superport_table.dart';
|
|
import '../../../../widgets/components/superport_pagination_controls.dart';
|
|
import '../../../../widgets/components/feature_disabled_placeholder.dart';
|
|
import '../../domain/entities/approval.dart';
|
|
import '../../domain/repositories/approval_repository.dart';
|
|
import '../../domain/repositories/approval_template_repository.dart';
|
|
import '../../domain/usecases/get_approval_draft_use_case.dart';
|
|
import '../../domain/usecases/list_approval_drafts_use_case.dart';
|
|
import '../../domain/usecases/save_approval_draft_use_case.dart';
|
|
import '../../../inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
|
|
import '../../../inventory/shared/widgets/employee_autocomplete_field.dart';
|
|
import '../controllers/approval_controller.dart';
|
|
import '../dialogs/approval_detail_dialog.dart';
|
|
|
|
const _approvalsResourcePath = PermissionResources.approvals;
|
|
|
|
/// 결재 관리 최상위 페이지.
|
|
///
|
|
/// 기능 플래그에 따라 실제 화면 또는 비활성 안내 화면을 보여준다.
|
|
class ApprovalPage extends StatelessWidget {
|
|
const ApprovalPage({super.key, this.routeUri});
|
|
|
|
final Uri? routeUri;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final enabled = Environment.flag('FEATURE_APPROVALS_ENABLED');
|
|
if (!enabled) {
|
|
return AppLayout(
|
|
title: '결재 관리',
|
|
subtitle: '결재 요청 상태와 단계/이력을 한 화면에서 확인합니다.',
|
|
breadcrumbs: const [
|
|
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
|
|
AppBreadcrumbItem(label: '결재', path: '/approvals/requests'),
|
|
AppBreadcrumbItem(label: '결재 관리'),
|
|
],
|
|
actions: [
|
|
Tooltip(
|
|
message: '백엔드 연동 후 사용 가능합니다.',
|
|
child: ShadButton(
|
|
onPressed: null,
|
|
leading: const Icon(lucide.LucideIcons.plus, size: 16),
|
|
child: const Text('신규 결재'),
|
|
),
|
|
),
|
|
],
|
|
child: const FeatureDisabledPlaceholder(
|
|
title: '결재 관리 기능 준비 중',
|
|
description: '결재 API 연결이 완료되면 실제 결재 요청 목록과 단계 정보를 제공합니다.',
|
|
),
|
|
);
|
|
}
|
|
|
|
return _ApprovalEnabledPage(routeUri: routeUri);
|
|
}
|
|
}
|
|
|
|
/// 결재 기능이 활성화되었을 때 사용되는 실제 페이지 위젯.
|
|
class _ApprovalEnabledPage extends StatefulWidget {
|
|
const _ApprovalEnabledPage({this.routeUri});
|
|
|
|
final Uri? routeUri;
|
|
|
|
@override
|
|
State<_ApprovalEnabledPage> createState() => _ApprovalEnabledPageState();
|
|
}
|
|
|
|
class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
|
late final ApprovalController _controller;
|
|
final TextEditingController _transactionController = TextEditingController();
|
|
final TextEditingController _requesterController = TextEditingController();
|
|
final FocusNode _transactionFocus = FocusNode();
|
|
InventoryEmployeeSuggestion? _selectedRequester;
|
|
final intl.DateFormat _dateTimeFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
|
|
String? _lastError;
|
|
String? _lastAccessDeniedMessage;
|
|
String? _pendingRouteSelection;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_pendingRouteSelection = _parseRouteSelection(widget.routeUri);
|
|
_controller = ApprovalController(
|
|
approvalRepository: GetIt.I<ApprovalRepository>(),
|
|
templateRepository: GetIt.I<ApprovalTemplateRepository>(),
|
|
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,
|
|
)..addListener(_handleControllerUpdate);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
await Future.wait([
|
|
_controller.loadActionOptions(),
|
|
_controller.loadTemplates(),
|
|
_controller.loadStatusLookups(),
|
|
]);
|
|
await _controller.fetch();
|
|
_applyRouteSelectionIfNeeded(
|
|
_controller.result?.items ?? const <Approval>[],
|
|
);
|
|
});
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant _ApprovalEnabledPage oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.routeUri != widget.routeUri) {
|
|
_pendingRouteSelection = _parseRouteSelection(widget.routeUri);
|
|
final currentResult = _controller.result;
|
|
if (currentResult != null) {
|
|
_applyRouteSelectionIfNeeded(currentResult.items);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _handleControllerUpdate() {
|
|
final error = _controller.errorMessage;
|
|
if (_controller.isAccessDenied) {
|
|
final message = _controller.accessDeniedMessage ?? '결재를 조회할 권한이 없습니다.';
|
|
if (mounted) {
|
|
if (_lastAccessDeniedMessage != message) {
|
|
SuperportToast.warning(context, message);
|
|
_lastAccessDeniedMessage = message;
|
|
}
|
|
final router = GoRouter.maybeOf(context);
|
|
router?.go(dashboardRoutePath);
|
|
}
|
|
_controller.acknowledgeAccessDenied();
|
|
return;
|
|
} else {
|
|
_lastAccessDeniedMessage = null;
|
|
}
|
|
if (error != null && error != _lastError && mounted) {
|
|
_lastError = error;
|
|
SuperportToast.error(context, error);
|
|
_controller.clearError();
|
|
}
|
|
}
|
|
|
|
/// 라우트 쿼리에서 선택된 결재번호를 읽어온다.
|
|
String? _parseRouteSelection(Uri? routeUri) {
|
|
final value = routeUri?.queryParameters['selected']?.trim();
|
|
if (value == null || value.isEmpty) {
|
|
return null;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/// 최초 로딩 시 라우트에서 전달된 결재번호가 있으면 자동으로 상세를 연다.
|
|
void _applyRouteSelectionIfNeeded(List<Approval> approvals) {
|
|
final target = _pendingRouteSelection;
|
|
if (target == null) {
|
|
return;
|
|
}
|
|
for (final approval in approvals) {
|
|
if (approval.approvalNo == target && approval.id != null) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
unawaited(_openApprovalDetailDialog(approval));
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
_pendingRouteSelection = null;
|
|
}
|
|
|
|
Future<void> _openApprovalDetailDialog(Approval approval) async {
|
|
final id = approval.id;
|
|
if (id == null) {
|
|
SuperportToast.error(context, 'ID 정보가 없어 상세를 열 수 없습니다.');
|
|
return;
|
|
}
|
|
|
|
await _controller.selectApproval(id);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
final selected = _controller.selected;
|
|
final error = _controller.errorMessage;
|
|
if (selected == null) {
|
|
if (error != null) {
|
|
SuperportToast.error(context, error);
|
|
_controller.clearError();
|
|
} else {
|
|
SuperportToast.error(context, '결재 상세 정보를 불러오지 못했습니다.');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (_controller.templates.isEmpty && !_controller.isLoadingTemplates) {
|
|
await _controller.loadTemplates(force: true);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
final permissionScope = PermissionScope.of(context);
|
|
final canPerformStepActions = permissionScope.can(
|
|
_approvalsResourcePath,
|
|
PermissionAction.approve,
|
|
);
|
|
final canApplyTemplate = permissionScope.can(
|
|
_approvalsResourcePath,
|
|
PermissionAction.edit,
|
|
);
|
|
|
|
await showApprovalDetailDialog(
|
|
context: context,
|
|
controller: _controller,
|
|
dateFormat: _dateTimeFormat,
|
|
canPerformStepActions: canPerformStepActions,
|
|
canApplyTemplate: canApplyTemplate,
|
|
);
|
|
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
_controller.clearSelection();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.removeListener(_handleControllerUpdate);
|
|
_controller.dispose();
|
|
_transactionController.dispose();
|
|
_requesterController.dispose();
|
|
_transactionFocus.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = ShadTheme.of(context);
|
|
|
|
return AnimatedBuilder(
|
|
animation: _controller,
|
|
builder: (context, _) {
|
|
final result = _controller.result;
|
|
final approvals = result?.items ?? const <Approval>[];
|
|
if (result != null) {
|
|
_applyRouteSelectionIfNeeded(approvals);
|
|
}
|
|
final totalCount = result?.total ?? 0;
|
|
final currentPage = result?.page ?? 1;
|
|
final totalPages = result == null || result.pageSize == 0
|
|
? 1
|
|
: (result.total / result.pageSize).ceil().clamp(1, 9999);
|
|
|
|
return AppLayout(
|
|
title: '결재 관리',
|
|
subtitle: '결재 요청 상태와 단계/이력을 한 화면에서 확인합니다.',
|
|
breadcrumbs: const [
|
|
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
|
|
AppBreadcrumbItem(label: '결재', path: '/approvals/requests'),
|
|
AppBreadcrumbItem(label: '결재 관리'),
|
|
],
|
|
actions: [
|
|
ShadButton(
|
|
leading: const Icon(lucide.LucideIcons.plus, size: 16),
|
|
onPressed: _openCreateApprovalDialog,
|
|
child: const Text('신규 결재'),
|
|
),
|
|
],
|
|
toolbar: FilterBar(
|
|
actionConfig: FilterBarActionConfig(
|
|
onApply: _applyFilters,
|
|
onReset: _resetFilters,
|
|
hasPendingChanges: false,
|
|
hasActiveFilters: _hasFilters(),
|
|
applyEnabled: !_controller.isLoadingList,
|
|
resetLabel: '필터 초기화',
|
|
resetKey: const ValueKey('approval_filter_reset'),
|
|
resetEnabled: !_controller.isLoadingList && _hasFilters(),
|
|
showReset: true,
|
|
),
|
|
children: [
|
|
SizedBox(
|
|
width: 180,
|
|
child: ShadInput(
|
|
controller: _transactionController,
|
|
focusNode: _transactionFocus,
|
|
enabled: !_controller.isLoadingList,
|
|
keyboardType: TextInputType.number,
|
|
placeholder: const Text('트랜잭션 ID 입력'),
|
|
leading: const Icon(lucide.LucideIcons.hash, size: 16),
|
|
onChanged: (_) => setState(() {}),
|
|
onSubmitted: (_) => _applyFilters(),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 280,
|
|
child: InventoryEmployeeAutocompleteField(
|
|
controller: _requesterController,
|
|
initialSuggestion: _selectedRequester,
|
|
enabled: !_controller.isLoadingList,
|
|
onSuggestionSelected: (suggestion) {
|
|
setState(() => _selectedRequester = suggestion);
|
|
},
|
|
onChanged: () {
|
|
setState(() {
|
|
final selectedLabel = _selectedRequester == null
|
|
? ''
|
|
: '${_selectedRequester!.name} (${_selectedRequester!.employeeNo})';
|
|
if (_requesterController.text.trim() != selectedLabel) {
|
|
_selectedRequester = null;
|
|
}
|
|
});
|
|
},
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 200,
|
|
child: Tooltip(
|
|
message: '전체 상태 선택 시 임시저장·상신·진행중 결재까지 함께 조회합니다.',
|
|
waitDuration: const Duration(milliseconds: 200),
|
|
child: ShadSelect<ApprovalStatusFilter>(
|
|
key: ValueKey(_controller.statusFilter),
|
|
initialValue: _controller.statusFilter,
|
|
selectedOptionBuilder: (context, value) =>
|
|
Text(_statusLabel(value)),
|
|
onChanged: (value) {
|
|
if (value == null) return;
|
|
_controller.updateStatusFilter(value);
|
|
_controller.fetch(page: 1);
|
|
},
|
|
options: ApprovalStatusFilter.values
|
|
.map(
|
|
(filter) => ShadOption(
|
|
value: filter,
|
|
child: Text(_statusLabel(filter)),
|
|
),
|
|
)
|
|
.toList(),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
ShadCard(
|
|
title: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text('결재 목록', style: theme.textTheme.h3),
|
|
Text('$totalCount건', style: theme.textTheme.muted),
|
|
],
|
|
),
|
|
footer: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
'페이지 $currentPage / $totalPages',
|
|
style: theme.textTheme.small,
|
|
),
|
|
SuperportPaginationControls(
|
|
currentPage: currentPage,
|
|
totalPages: totalPages,
|
|
isBusy: _controller.isLoadingList,
|
|
onPageSelected: (page) => _controller.fetch(page: page),
|
|
),
|
|
],
|
|
),
|
|
child: _controller.isLoadingList
|
|
? const Padding(
|
|
padding: EdgeInsets.all(48),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
)
|
|
: approvals.isEmpty
|
|
? Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Text(
|
|
'조건에 맞는 결재 내역이 없습니다.',
|
|
style: theme.textTheme.muted,
|
|
),
|
|
)
|
|
: _ApprovalTable(
|
|
approvals: approvals,
|
|
dateFormat: _dateTimeFormat,
|
|
onView: (approval) {
|
|
unawaited(_openApprovalDetailDialog(approval));
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// 신규 결재 등록 다이얼로그를 열어 UI 단계에서 필요한 필드와 안내를 제공한다.
|
|
Future<void> _openCreateApprovalDialog() async {
|
|
final transactionController = TextEditingController();
|
|
final requesterController = TextEditingController();
|
|
final noteController = TextEditingController();
|
|
String? createdApprovalNo;
|
|
InventoryEmployeeSuggestion? requesterSelection;
|
|
int? statusId = _controller.defaultApprovalStatusId;
|
|
String? transactionError;
|
|
String? statusError;
|
|
String? requesterError;
|
|
|
|
final created = await showDialog<bool>(
|
|
context: context,
|
|
builder: (_) {
|
|
return StatefulBuilder(
|
|
builder: (context, setState) {
|
|
return AnimatedBuilder(
|
|
animation: _controller,
|
|
builder: (context, _) {
|
|
final shadTheme = ShadTheme.of(context);
|
|
final materialTheme = Theme.of(context);
|
|
final statusOptions = _controller.approvalStatusOptions;
|
|
final isSubmitting = _controller.isSubmitting;
|
|
statusId ??= _controller.defaultApprovalStatusId;
|
|
|
|
return SuperportDialog(
|
|
title: '신규 결재 등록',
|
|
description: '트랜잭션과 결재 정보를 입력하면 즉시 생성됩니다.',
|
|
constraints: const BoxConstraints(maxWidth: 540),
|
|
actions: [
|
|
ShadButton.ghost(
|
|
onPressed: isSubmitting
|
|
? null
|
|
: () => Navigator.of(
|
|
context,
|
|
rootNavigator: true,
|
|
).pop(false),
|
|
child: const Text('취소'),
|
|
),
|
|
ShadButton(
|
|
key: const ValueKey('approval_create_submit'),
|
|
onPressed: isSubmitting
|
|
? null
|
|
: () async {
|
|
final transactionText = transactionController.text
|
|
.trim();
|
|
final transactionId = int.tryParse(
|
|
transactionText,
|
|
);
|
|
final note = noteController.text.trim();
|
|
final hasStatuses = statusOptions.isNotEmpty;
|
|
|
|
setState(() {
|
|
transactionError = transactionText.isEmpty
|
|
? '트랜잭션 ID를 입력하세요.'
|
|
: (transactionId == null
|
|
? '트랜잭션 ID는 숫자만 입력하세요.'
|
|
: null);
|
|
statusError = (!hasStatuses || statusId == null)
|
|
? '결재 상태를 선택하세요.'
|
|
: null;
|
|
requesterError = requesterSelection == null
|
|
? '상신자를 선택하세요.'
|
|
: null;
|
|
});
|
|
|
|
if (transactionError != null ||
|
|
statusError != null ||
|
|
requesterError != null) {
|
|
return;
|
|
}
|
|
|
|
final input = ApprovalCreateInput(
|
|
transactionId: transactionId!,
|
|
approvalStatusId: statusId!,
|
|
requestedById: requesterSelection!.id,
|
|
note: note.isEmpty ? null : note,
|
|
);
|
|
final result = await _controller.createApproval(
|
|
input,
|
|
);
|
|
if (!mounted || !context.mounted) {
|
|
return;
|
|
}
|
|
if (result != null) {
|
|
createdApprovalNo = result.approvalNo;
|
|
Navigator.of(
|
|
context,
|
|
rootNavigator: true,
|
|
).pop(true);
|
|
}
|
|
},
|
|
child: isSubmitting
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Text('등록'),
|
|
),
|
|
],
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: shadTheme.colorScheme.mutedForeground
|
|
.withValues(alpha: 0.08),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Text(
|
|
'결재번호는 저장 시 자동으로 생성됩니다.',
|
|
style: shadTheme.textTheme.muted,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text('트랜잭션 ID', style: shadTheme.textTheme.small),
|
|
const SizedBox(height: 8),
|
|
ShadInput(
|
|
key: const ValueKey('approval_create_transaction'),
|
|
controller: transactionController,
|
|
enabled: !isSubmitting,
|
|
placeholder: const Text('예: 9001'),
|
|
onChanged: (_) {
|
|
if (transactionError != null) {
|
|
setState(() => transactionError = null);
|
|
}
|
|
},
|
|
),
|
|
if (transactionError != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 6),
|
|
child: Text(
|
|
transactionError!,
|
|
style: shadTheme.textTheme.small.copyWith(
|
|
color: materialTheme.colorScheme.error,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text('결재 상태', style: shadTheme.textTheme.small),
|
|
const SizedBox(height: 8),
|
|
if (statusOptions.isEmpty)
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: shadTheme.colorScheme.border,
|
|
),
|
|
),
|
|
child: Text(
|
|
'결재 상태 정보를 불러오지 못했습니다. 다시 시도해주세요.',
|
|
style: shadTheme.textTheme.muted,
|
|
),
|
|
)
|
|
else
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
ShadSelect<int>(
|
|
key: ValueKey(statusOptions.length),
|
|
initialValue: statusId,
|
|
enabled: !isSubmitting,
|
|
placeholder: const Text('결재 상태 선택'),
|
|
onChanged: (value) {
|
|
setState(() {
|
|
statusId = value;
|
|
statusError = null;
|
|
});
|
|
},
|
|
selectedOptionBuilder: (context, value) {
|
|
final selected = _controller.approvalStatusById(
|
|
value,
|
|
);
|
|
return Text(selected?.name ?? '결재 상태 선택');
|
|
},
|
|
options: statusOptions
|
|
.map(
|
|
(item) => ShadOption<int>(
|
|
value: item.id,
|
|
child: Text(item.name),
|
|
),
|
|
)
|
|
.toList(),
|
|
),
|
|
if (statusError != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 6),
|
|
child: Text(
|
|
statusError!,
|
|
style: shadTheme.textTheme.small.copyWith(
|
|
color: materialTheme.colorScheme.error,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text('상신자', style: shadTheme.textTheme.small),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: InventoryEmployeeAutocompleteField(
|
|
controller: requesterController,
|
|
initialSuggestion: requesterSelection,
|
|
onSuggestionSelected: (suggestion) {
|
|
setState(() {
|
|
requesterSelection = suggestion;
|
|
requesterError = null;
|
|
});
|
|
},
|
|
onChanged: () {
|
|
if (requesterController.text.trim().isEmpty) {
|
|
setState(() {
|
|
requesterSelection = null;
|
|
});
|
|
}
|
|
},
|
|
enabled: !isSubmitting,
|
|
placeholder: '상신자 이름 또는 사번 검색',
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
ShadButton.ghost(
|
|
onPressed:
|
|
requesterSelection == null &&
|
|
requesterController.text.isEmpty
|
|
? null
|
|
: () {
|
|
setState(() {
|
|
requesterSelection = null;
|
|
requesterController.clear();
|
|
requesterError = null;
|
|
});
|
|
},
|
|
child: const Text('초기화'),
|
|
),
|
|
],
|
|
),
|
|
if (requesterError != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 6),
|
|
child: Text(
|
|
requesterError!,
|
|
style: shadTheme.textTheme.small.copyWith(
|
|
color: materialTheme.colorScheme.error,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text('비고 (선택)', style: shadTheme.textTheme.small),
|
|
const SizedBox(height: 8),
|
|
ShadTextarea(
|
|
key: const ValueKey('approval_create_note'),
|
|
controller: noteController,
|
|
enabled: !isSubmitting,
|
|
minHeight: 120,
|
|
maxHeight: 220,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: shadTheme.colorScheme.mutedForeground
|
|
.withValues(alpha: 0.08),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: const [
|
|
Text(
|
|
'저장 안내',
|
|
style: TextStyle(fontWeight: FontWeight.w600),
|
|
),
|
|
SizedBox(height: 6),
|
|
Text(
|
|
'저장 시 결재가 생성되고 첫 단계와 현재 상태가 API 규격에 맞춰 초기화됩니다. 등록 후 목록이 자동으로 갱신됩니다.',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
transactionController.dispose();
|
|
requesterController.dispose();
|
|
noteController.dispose();
|
|
|
|
if (created == true && mounted) {
|
|
final number = createdApprovalNo ?? '-';
|
|
SuperportToast.success(context, '결재를 생성했습니다. ($number)');
|
|
}
|
|
}
|
|
|
|
void _applyFilters() {
|
|
final transactionText = _transactionController.text.trim();
|
|
if (transactionText.isNotEmpty) {
|
|
final transactionId = int.tryParse(transactionText);
|
|
if (transactionId == null) {
|
|
SuperportToast.error(context, '트랜잭션 ID는 숫자만 입력해야 합니다.');
|
|
return;
|
|
}
|
|
_controller.updateTransactionFilter(transactionId);
|
|
} else {
|
|
_controller.updateTransactionFilter(null);
|
|
}
|
|
|
|
final requester = _selectedRequester;
|
|
if (requester != null) {
|
|
_controller.updateRequestedByFilter(
|
|
id: requester.id,
|
|
name: requester.name,
|
|
employeeNo: requester.employeeNo,
|
|
);
|
|
} else if (_requesterController.text.trim().isEmpty) {
|
|
_controller.updateRequestedByFilter(id: null);
|
|
}
|
|
|
|
_controller.fetch(page: 1);
|
|
}
|
|
|
|
void _resetFilters() {
|
|
_transactionController.clear();
|
|
_requesterController.clear();
|
|
setState(() => _selectedRequester = null);
|
|
_transactionFocus.requestFocus();
|
|
_controller.clearFilters();
|
|
_controller.fetch(page: 1);
|
|
}
|
|
|
|
bool _hasFilters() {
|
|
return _transactionController.text.trim().isNotEmpty ||
|
|
_requesterController.text.trim().isNotEmpty ||
|
|
_selectedRequester != null ||
|
|
_controller.transactionIdFilter != null ||
|
|
_controller.requestedById != null ||
|
|
_controller.statusFilter != ApprovalStatusFilter.all;
|
|
}
|
|
|
|
String _statusLabel(ApprovalStatusFilter filter) {
|
|
return _controller.statusLabel(filter);
|
|
}
|
|
}
|
|
|
|
class _ApprovalTable extends StatelessWidget {
|
|
const _ApprovalTable({
|
|
required this.approvals,
|
|
required this.dateFormat,
|
|
required this.onView,
|
|
});
|
|
|
|
final List<Approval> approvals;
|
|
final intl.DateFormat dateFormat;
|
|
final void Function(Approval approval) onView;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final header = [
|
|
'ID',
|
|
'결재번호',
|
|
'트랜잭션번호',
|
|
'상태',
|
|
'상신자',
|
|
'요청일시',
|
|
'최종결정일시',
|
|
'비고',
|
|
].map((text) => ShadTableCell.header(child: Text(text))).toList();
|
|
|
|
final rows = <List<ShadTableCell>>[];
|
|
for (var index = 0; index < approvals.length; index++) {
|
|
final approval = approvals[index];
|
|
final cells = <ShadTableCell>[
|
|
ShadTableCell(
|
|
child: GestureDetector(
|
|
key: ValueKey('approval_row_${approval.id ?? index}'),
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: () => onView(approval),
|
|
child: Text(approval.id?.toString() ?? '-'),
|
|
),
|
|
),
|
|
ShadTableCell(child: Text(approval.approvalNo)),
|
|
ShadTableCell(child: Text(approval.transactionNo)),
|
|
ShadTableCell(child: Text(approval.status.name)),
|
|
ShadTableCell(child: Text(approval.requester.name)),
|
|
ShadTableCell(
|
|
child: Text(dateFormat.format(approval.requestedAt.toLocal())),
|
|
),
|
|
ShadTableCell(
|
|
child: Text(
|
|
approval.decidedAt == null
|
|
? '-'
|
|
: dateFormat.format(approval.decidedAt!.toLocal()),
|
|
),
|
|
),
|
|
ShadTableCell(
|
|
child: Text(approval.note?.isEmpty ?? true ? '-' : approval.note!),
|
|
),
|
|
];
|
|
|
|
rows.add(cells);
|
|
}
|
|
|
|
return SuperportTable.fromCells(
|
|
header: header,
|
|
rows: rows,
|
|
rowHeight: 56,
|
|
maxHeight: 520,
|
|
onRowTap: (index) {
|
|
if (index < 0 || index >= approvals.length) {
|
|
return;
|
|
}
|
|
onView(approvals[index]);
|
|
},
|
|
columnSpanExtent: (index) {
|
|
switch (index) {
|
|
case 1:
|
|
case 2:
|
|
return const FixedTableSpanExtent(180);
|
|
case 3:
|
|
case 4:
|
|
return const FixedTableSpanExtent(140);
|
|
case 7:
|
|
return const FixedTableSpanExtent(220);
|
|
default:
|
|
return const FixedTableSpanExtent(140);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}
|