From c072eb132832799fa9f175d17c59b62f9ed1ebc5 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Tue, 14 Oct 2025 18:06:40 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9E=AC=EA=B3=A0=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=EC=A0=84=EC=9D=B4=20=ED=94=8C=EB=9E=98=EA=B7=B8=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=EB=B0=8F=20=EC=8B=A4=ED=8C=A8=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EC=A0=95=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/pages/inbound_page.dart | 1377 ++++++++++---- .../presentation/pages/outbound_page.dart | 1661 ++++++++++++----- .../presentation/pages/rental_page.dart | 1515 ++++++++++----- .../login/presentation/pages/login_page.dart | 103 + .../presentation/pages/reporting_page.dart | 414 +++- .../controllers/inbound_controller_test.dart | 246 +++ .../reporting/reporting_page_test.dart | 101 +- test/helpers/inventory_test_stubs.dart | 937 ++++++++++ test/helpers/test_app.dart | 2 +- 9 files changed, 5069 insertions(+), 1287 deletions(-) create mode 100644 test/features/inventory/inbound/presentation/controllers/inbound_controller_test.dart create mode 100644 test/helpers/inventory_test_stubs.dart diff --git a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart index 738c5ca..5d7a9e5 100644 --- a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart +++ b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/feedback.dart'; @@ -12,9 +14,22 @@ import 'package:superport_v2/widgets/components/empty_state.dart'; import 'package:superport_v2/widgets/components/responsive.dart'; import 'package:superport_v2/widgets/components/superport_dialog.dart'; import 'package:superport_v2/widgets/components/superport_date_picker.dart'; -import 'package:superport_v2/features/inventory/shared/catalogs.dart'; import 'package:superport_v2/features/inventory/shared/widgets/product_autocomplete_field.dart'; +import 'package:superport_v2/features/inventory/shared/widgets/employee_autocomplete_field.dart'; +import 'package:superport_v2/features/inventory/shared/widgets/warehouse_select_field.dart'; +import 'package:superport_v2/core/config/environment.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/inventory/inbound/presentation/controllers/inbound_controller.dart'; +import 'package:superport_v2/features/inventory/inbound/presentation/models/inbound_record.dart'; +import 'package:superport_v2/features/inventory/inbound/presentation/specs/inbound_table_spec.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/features/inventory/transactions/presentation/services/transaction_detail_sync_service.dart'; +import '../../../lookups/domain/entities/lookup_item.dart'; +import '../../../lookups/domain/repositories/inventory_lookup_repository.dart'; const String _inboundTransactionTypeId = '입고'; @@ -32,6 +47,8 @@ class InboundPage extends StatefulWidget { class _InboundPageState extends State { final TextEditingController _searchController = TextEditingController(); final DateFormat _dateFormatter = DateFormat('yyyy-MM-dd'); + final TransactionDetailSyncService _detailSyncService = + const TransactionDetailSyncService(); final NumberFormat _currencyFormatter = NumberFormat.currency( locale: 'ko_KR', symbol: '₩', @@ -40,28 +57,37 @@ class _InboundPageState extends State { String _query = ''; String _pendingQuery = ''; - String? _appliedWarehouse; - String? _pendingWarehouse; + int? _appliedWarehouseId; + int? _pendingWarehouseId; + String? _appliedWarehouseLabel; + String? _pendingWarehouseLabel; String? _appliedStatus; String? _pendingStatus; DateTimeRange? _appliedDateRange; DateTimeRange? _pendingDateRange; - final List _records = _mockRecords; + List _records = []; InboundRecord? _selectedRecord; - static const _statusOptions = ['작성중', '승인대기', '승인완료']; - static const _warehouseOptions = ['서울 1창고', '부산 센터', '대전 물류']; - static const _pageSizeOptions = [10, 20, 50]; - static const _includeOptions = ['lines']; + PaginatedResult? _result; + bool _isLoading = false; + String? _errorMessage; + Set _processingTransactionIds = {}; - Set _appliedIncludes = {..._includeOptions}; - Set _pendingIncludes = {..._includeOptions}; + late List _statusOptions; + final Map _statusLookup = {}; + LookupItem? _transactionTypeLookup; + Set _appliedIncludes = {...InboundTableSpec.defaultIncludeOptions}; + Set _pendingIncludes = {...InboundTableSpec.defaultIncludeOptions}; late final ShadSelectController _includeController; int _currentPage = 1; - int _pageSize = _pageSizeOptions.first; + int _pageSize = InboundTableSpec.pageSizeOptions.first; _InboundSortField _sortField = _InboundSortField.processedAt; bool _sortAscending = false; + InboundController? _controller; + + bool get _transitionsEnabled => + Environment.flag('FEATURE_STOCK_TRANSITIONS_ENABLED', defaultValue: true); @override void initState() { @@ -69,7 +95,93 @@ class _InboundPageState extends State { _includeController = ShadSelectController( initialValue: _pendingIncludes.toSet(), ); + _statusOptions = List.from(InboundTableSpec.fallbackStatusOptions); + _controller = _createController(); + _controller?.addListener(_handleControllerChanged); _applyRouteParameters(widget.routeUri, initialize: true); + _initializeController(); + } + + InboundController? _createController() { + final getIt = GetIt.I; + if (!getIt.isRegistered() || + !getIt.isRegistered() || + !getIt.isRegistered()) { + return null; + } + return InboundController( + transactionRepository: getIt(), + lineRepository: getIt(), + lookupRepository: getIt(), + fallbackStatusOptions: InboundTableSpec.fallbackStatusOptions, + transactionTypeKeywords: InboundTableSpec.transactionTypeKeywords, + ); + } + + void _initializeController() { + final controller = _controller; + if (controller == null) { + return; + } + Future.microtask(() async { + await controller.loadStatusOptions(); + final hasType = await controller.resolveTransactionType(); + if (!mounted) { + return; + } + if (hasType) { + await _fetchTransactions(page: _currentPage); + } + }); + } + + void _handleControllerChanged() { + if (!mounted) { + return; + } + final controller = _controller; + if (controller == null) { + return; + } + final statusSource = controller.statusOptions.isEmpty + ? controller.fallbackStatusOptions + : controller.statusOptions; + final resolvedStatusOptions = List.from(statusSource); + final resolvedLookup = Map.from( + controller.statusLookup, + ); + + var pendingStatus = _pendingStatus; + if (pendingStatus != null && + !resolvedStatusOptions.contains(pendingStatus)) { + pendingStatus = null; + } + var appliedStatus = _appliedStatus; + if (appliedStatus != null && + !resolvedStatusOptions.contains(appliedStatus)) { + appliedStatus = null; + } + + setState(() { + _statusOptions = resolvedStatusOptions.isEmpty + ? List.from(InboundTableSpec.fallbackStatusOptions) + : resolvedStatusOptions; + _statusLookup + ..clear() + ..addAll(resolvedLookup); + _pendingStatus = pendingStatus; + _appliedStatus = appliedStatus; + _result = controller.result; + _records = controller.records.toList(); + _isLoading = controller.isLoading; + _errorMessage = controller.errorMessage; + _processingTransactionIds = Set.from( + controller.processingTransactionIds, + ); + _transactionTypeLookup = + controller.transactionType ?? _transactionTypeLookup; + _refreshSelection(); + }); } @override @@ -82,24 +194,62 @@ class _InboundPageState extends State { @override void dispose() { + _controller?.removeListener(_handleControllerChanged); + _controller?.dispose(); _searchController.dispose(); _includeController.dispose(); super.dispose(); } + Future _fetchTransactions({int? page}) async { + final controller = _controller; + final lookup = controller?.transactionType ?? _transactionTypeLookup; + if (controller == null || lookup == null) { + return; + } + + final targetPage = page ?? _currentPage; + final includes = _appliedIncludes.isEmpty + ? InboundTableSpec.defaultIncludeOptions.toList(growable: false) + : _appliedIncludes.toList(growable: false); + final statusItem = _appliedStatus == null + ? null + : _statusLookup[_appliedStatus!]; + + setState(() { + _currentPage = targetPage; + _transactionTypeLookup = lookup; + }); + + final filter = StockTransactionListFilter( + page: targetPage, + pageSize: _pageSize, + query: _query.isEmpty ? null : _query, + transactionTypeId: lookup.id, + transactionStatusId: statusItem != null && statusItem.id > 0 + ? statusItem.id + : null, + from: _appliedDateRange?.start, + to: _appliedDateRange?.end, + include: includes, + ); + + await controller.fetchTransactions(filter: filter); + } + @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); final filtered = _filteredRecords; final responsive = ResponsiveBreakpoints.of(context); - final totalPages = _calculateTotalPages(filtered.length); + final totalItems = _result?.total ?? filtered.length; + final totalPages = _calculateTotalPages(totalItems); final int currentPage = totalPages <= 1 ? 1 : (_currentPage < 1 ? 1 : (_currentPage > totalPages ? totalPages : _currentPage)); - final startIndex = (currentPage - 1) * _pageSize; - final visibleRecords = filtered.skip(startIndex).take(_pageSize).toList(); + final visibleRecords = filtered; return AppLayout( title: '입고 관리', @@ -111,7 +261,7 @@ class _InboundPageState extends State { ], actions: [ PermissionGate( - resource: '/inventory/inbound', + resource: PermissionResources.stockTransactions, action: PermissionAction.create, child: ShadButton( leading: const Icon(lucide.LucideIcons.plus, size: 16), @@ -120,7 +270,7 @@ class _InboundPageState extends State { ), ), PermissionGate( - resource: '/inventory/inbound', + resource: PermissionResources.stockTransactions, action: PermissionAction.edit, child: ShadButton.outline( leading: const Icon(lucide.LucideIcons.pencil, size: 16), @@ -143,7 +293,7 @@ class _InboundPageState extends State { width: 260, child: ShadInput( controller: _searchController, - placeholder: const Text('트랜잭션번호, 작성자, 제품 검색'), + placeholder: const Text(InboundTableSpec.searchPlaceholder), leading: const Icon(lucide.LucideIcons.search, size: 16), onChanged: (_) { setState(() { @@ -159,24 +309,24 @@ class _InboundPageState extends State { value: _pendingDateRange, dateFormat: _dateFormatter, onChanged: (range) => setState(() => _pendingDateRange = range), - firstDate: DateTime(2020), - lastDate: DateTime(2030), + firstDate: InboundTableSpec.dateRangeFirstDate, + lastDate: InboundTableSpec.dateRangeLastDate, ), ), SizedBox( width: 200, - child: ShadSelect( - key: ValueKey(_pendingWarehouse ?? 'all'), - initialValue: _pendingWarehouse, - selectedOptionBuilder: (_, value) => Text(value ?? '전체 창고'), - onChanged: (value) { - setState(() => _pendingWarehouse = value); + child: InventoryWarehouseSelectField( + key: ValueKey(_pendingWarehouseId ?? 'all'), + initialWarehouseId: _pendingWarehouseId, + includeAllOption: true, + allLabel: InboundTableSpec.allWarehouseLabel, + placeholder: const Text(InboundTableSpec.allWarehouseLabel), + onChanged: (option) { + setState(() { + _pendingWarehouseId = option?.id; + _pendingWarehouseLabel = option?.name; + }); }, - options: [ - const ShadOption(value: null, child: Text('전체 창고')), - for (final option in _warehouseOptions) - ShadOption(value: option, child: Text(option)), - ], ), ), SizedBox( @@ -184,12 +334,16 @@ class _InboundPageState extends State { child: ShadSelect( key: ValueKey(_pendingStatus ?? 'all'), initialValue: _pendingStatus, - selectedOptionBuilder: (_, value) => Text(value ?? '전체 상태'), + selectedOptionBuilder: (_, value) => + Text(value ?? InboundTableSpec.allStatusLabel), onChanged: (value) { setState(() => _pendingStatus = value); }, options: [ - const ShadOption(value: null, child: Text('전체 상태')), + const ShadOption( + value: null, + child: Text(InboundTableSpec.allStatusLabel), + ), for (final option in _statusOptions) ShadOption(value: option, child: Text(option)), ], @@ -223,7 +377,7 @@ class _InboundPageState extends State { initialValues: _pendingIncludes, selectedOptionsBuilder: (context, values) { if (values.isEmpty) { - return const Text('Include 없음'); + return const Text(InboundTableSpec.includeEmptyLabel); } return Wrap( spacing: 6, @@ -236,7 +390,7 @@ class _InboundPageState extends State { horizontal: 8, vertical: 4, ), - child: Text(_includeLabel(value)), + child: Text(InboundTableSpec.includeLabel(value)), ), ), ], @@ -248,8 +402,11 @@ class _InboundPageState extends State { }); }, options: [ - for (final option in _includeOptions) - ShadOption(value: option, child: Text(_includeLabel(option))), + for (final option in InboundTableSpec.defaultIncludeOptions) + ShadOption( + value: option, + child: Text(InboundTableSpec.includeLabel(option)), + ), ], ), ), @@ -274,7 +431,7 @@ class _InboundPageState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('입고 내역', style: theme.textTheme.h3), - Text('${filtered.length}건', style: theme.textTheme.muted), + Text('$totalItems건', style: theme.textTheme.muted), ], ), child: Column( @@ -282,10 +439,15 @@ class _InboundPageState extends State { children: [ SizedBox( height: responsive.isMobile ? 520 : 420, - child: filtered.isEmpty - ? const SuperportEmptyState( - title: '입고 데이터가 없습니다.', - description: '검색어와 기간을 조정해 다시 시도하세요.', + child: _isLoading && filtered.isEmpty + ? const Center(child: CircularProgressIndicator()) + : filtered.isEmpty + ? SuperportEmptyState( + title: _errorMessage != null + ? '데이터를 불러오지 못했습니다.' + : '입고 데이터가 없습니다.', + description: + _errorMessage ?? '검색어와 기간을 조정해 다시 시도하세요.', ) : ResponsiveLayoutBuilder( mobile: (_) => _InboundMobileList( @@ -296,6 +458,19 @@ class _InboundPageState extends State { }, dateFormatter: _dateFormatter, currencyFormatter: _currencyFormatter, + transitionsEnabled: _transitionsEnabled, + onSubmit: _submitRecord, + onComplete: _completeRecord, + onApprove: _approveRecord, + onReject: _rejectRecord, + onCancel: _cancelRecord, + canSubmit: _canSubmit, + canComplete: _canComplete, + canApprove: _canApprove, + canReject: _canReject, + canCancel: _canCancel, + isProcessing: (record) => + _isProcessing(record.id) || _isLoading, ), tablet: (_) => _buildTableView( visibleRecords, @@ -326,9 +501,11 @@ class _InboundPageState extends State { _currentPage = 1; }); _updateRoute(page: 1, pageSize: value); + _fetchTransactions(page: 1); }, options: [ - for (final option in _pageSizeOptions) + for (final option + in InboundTableSpec.pageSizeOptions) ShadOption( value: option, child: Text('$option개 / 페이지'), @@ -339,7 +516,7 @@ class _InboundPageState extends State { Row( children: [ Text( - '${filtered.length}건 · 페이지 $currentPage / $totalPages', + '$totalItems건 · 페이지 $currentPage / $totalPages', style: theme.textTheme.small, ), const SizedBox(width: 12), @@ -387,6 +564,32 @@ class _InboundPageState extends State { dateFormatter: _dateFormatter, currencyFormatter: _currencyFormatter, onEdit: () => _handleEdit(_selectedRecord!), + transitionsEnabled: _transitionsEnabled, + onSubmit: _canSubmit(_selectedRecord!) + ? () async => _submitRecord(_selectedRecord!) + : null, + onComplete: _canComplete(_selectedRecord!) + ? () async => _completeRecord(_selectedRecord!) + : null, + onApprove: _canApprove(_selectedRecord!) + ? () async => _approveRecord(_selectedRecord!) + : null, + onReject: _canReject(_selectedRecord!) + ? () async => _rejectRecord(_selectedRecord!) + : null, + onCancel: _canCancel(_selectedRecord!) + ? () async => _cancelRecord(_selectedRecord!) + : null, + canSubmit: + !_isProcessing(_selectedRecord!.id) && !_isLoading, + canComplete: + !_isProcessing(_selectedRecord!.id) && !_isLoading, + canApprove: + !_isProcessing(_selectedRecord!.id) && !_isLoading, + canReject: + !_isProcessing(_selectedRecord!.id) && !_isLoading, + canCancel: + !_isProcessing(_selectedRecord!.id) && !_isLoading, ), ], ), @@ -412,8 +615,16 @@ class _InboundPageState extends State { range == null || (!record.processedAt.isBefore(range.start) && !record.processedAt.isAfter(range.end)); - final matchesWarehouse = - _appliedWarehouse == null || record.warehouse == _appliedWarehouse; + final matchesWarehouse = () { + if (_appliedWarehouseId != null) { + return record.warehouseId == _appliedWarehouseId; + } + if (_appliedWarehouseLabel != null && + _appliedWarehouseLabel!.isNotEmpty) { + return record.warehouse == _appliedWarehouseLabel; + } + return true; + }(); final matchesStatus = _appliedStatus == null || record.status == _appliedStatus; return matchesQuery && matchesRange && matchesWarehouse && matchesStatus; @@ -433,17 +644,17 @@ class _InboundPageState extends State { } List _buildRecordRow(InboundRecord record) { - final primaryItem = record.items.first; + final primaryItem = record.items.isNotEmpty ? record.items.first : null; return [ record.number.split('-').last, _dateFormatter.format(record.processedAt), record.warehouse, record.transactionNumber, - primaryItem.product, - primaryItem.manufacturer, - primaryItem.unit, + primaryItem?.product ?? '-', + primaryItem?.manufacturer ?? '-', + primaryItem?.unit ?? '-', record.totalQuantity.toString(), - _currencyFormatter.format(primaryItem.price), + _currencyFormatter.format(primaryItem?.price ?? 0), record.status, record.writer, record.itemCount.toString(), @@ -460,13 +671,15 @@ class _InboundPageState extends State { return ShadTable.list( header: [ for (final index in visibleColumns) - ShadTableCell.header(child: Text(_tableHeaders[index])), + ShadTableCell.header(child: Text(InboundTableSpec.headers[index])), ], children: [ for (final record in records) _buildTableCells(record, visibleColumns), ], - columnSpanExtent: (index) => const FixedTableSpanExtent(140), - rowSpanExtent: (index) => const FixedTableSpanExtent(56), + columnSpanExtent: (index) => + const FixedTableSpanExtent(InboundTableSpec.columnSpanWidth), + rowSpanExtent: (index) => + const FixedTableSpanExtent(InboundTableSpec.rowSpanHeight), onRowTap: (rowIndex) { setState(() { _selectedRecord = records[rowIndex]; @@ -476,14 +689,7 @@ class _InboundPageState extends State { } List _visibleColumnsFor(DeviceBreakpoint breakpoint) { - switch (breakpoint) { - case DeviceBreakpoint.desktop: - return List.generate(_tableHeaders.length, (index) => index); - case DeviceBreakpoint.tablet: - return const [0, 1, 2, 3, 4, 7, 8, 9, 10, 11, 12]; - case DeviceBreakpoint.mobile: - return const [0, 1, 2, 9, 10]; - } + return InboundTableSpec.visibleColumns(breakpoint); } List _buildTableCells( @@ -503,7 +709,6 @@ class _InboundPageState extends State { final record = await _showInboundFormDialog(); if (record != null) { setState(() { - _records.insert(0, record); _selectedRecord = record; }); } @@ -513,28 +718,216 @@ class _InboundPageState extends State { final updated = await _showInboundFormDialog(initial: record); if (updated != null) { setState(() { - final index = _records.indexWhere( - (element) => element.number == record.number, - ); - if (index != -1) { - _records[index] = updated; - _selectedRecord = updated; - } + _selectedRecord = updated; }); } } + bool _isProcessing(int? id) { + if (id == null) { + return false; + } + return _processingTransactionIds.contains(id); + } + + bool _canSubmit(InboundRecord record) { + if (!_transitionsEnabled) { + return false; + } + final normalized = record.status.trim(); + return normalized.contains('작성'); + } + + bool _canComplete(InboundRecord record) { + if (!_transitionsEnabled) { + return false; + } + final normalized = record.status.trim(); + return normalized.contains('대기') || normalized.contains('승인대기'); + } + + bool _canApprove(InboundRecord record) { + if (!_transitionsEnabled) { + return false; + } + final normalized = record.status.replaceAll(RegExp(r'\s+'), ''); + return normalized.contains('승인대기'); + } + + bool _canReject(InboundRecord record) { + if (!_transitionsEnabled) { + return false; + } + final normalized = record.status.replaceAll(RegExp(r'\s+'), ''); + return normalized.contains('승인대기'); + } + + bool _canCancel(InboundRecord record) { + if (!_transitionsEnabled) { + return false; + } + final normalized = record.status.replaceAll(RegExp(r'\s+'), ''); + return normalized.contains('작성중') || normalized.contains('승인대기'); + } + + /// 실패 객체에서 사용자에게 노출할 메시지를 추출한다. + String _failureMessage(Object error, String fallbackMessage) { + final failure = Failure.from(error); + final description = failure.describe(); + if (description.isEmpty) { + return fallbackMessage; + } + return description; + } + + Future _submitRecord(InboundRecord record) async { + final controller = _controller; + final id = record.id; + if (controller == null || id == null) { + if (mounted) { + SuperportToast.error(context, '상신할 대상을 찾을 수 없습니다.'); + } + return; + } + + try { + await controller.submitTransaction(id); + if (!mounted) { + return; + } + SuperportToast.success(context, '상신 요청이 완료되었습니다.'); + } catch (error) { + if (!mounted) { + return; + } + SuperportToast.error( + context, + _failureMessage(error, '상신 처리에 실패했습니다. 잠시 후 다시 시도하세요.'), + ); + } + } + + Future _completeRecord(InboundRecord record) async { + final controller = _controller; + final id = record.id; + if (controller == null || id == null) { + if (mounted) { + SuperportToast.error(context, '완료 처리할 대상을 찾을 수 없습니다.'); + } + return; + } + + try { + await controller.completeTransaction(id); + if (!mounted) { + return; + } + SuperportToast.success(context, '완료 처리되었습니다.'); + } catch (error) { + if (!mounted) { + return; + } + SuperportToast.error( + context, + _failureMessage(error, '완료 처리에 실패했습니다. 다시 시도하세요.'), + ); + } + } + + Future _approveRecord(InboundRecord record) async { + final controller = _controller; + final id = record.id; + if (controller == null || id == null) { + if (mounted) { + SuperportToast.error(context, '승인할 대상을 찾을 수 없습니다.'); + } + return; + } + + try { + await controller.approveTransaction(id); + if (!mounted) { + return; + } + SuperportToast.success(context, '승인 처리되었습니다.'); + } catch (error) { + if (!mounted) { + return; + } + SuperportToast.error( + context, + _failureMessage(error, '승인 처리에 실패했습니다. 잠시 후 다시 시도하세요.'), + ); + } + } + + Future _rejectRecord(InboundRecord record) async { + final controller = _controller; + final id = record.id; + if (controller == null || id == null) { + if (mounted) { + SuperportToast.error(context, '반려할 대상을 찾을 수 없습니다.'); + } + return; + } + + try { + await controller.rejectTransaction(id); + if (!mounted) { + return; + } + SuperportToast.success(context, '반려 처리되었습니다.'); + } catch (error) { + if (!mounted) { + return; + } + SuperportToast.error( + context, + _failureMessage(error, '반려 처리에 실패했습니다. 잠시 후 다시 시도하세요.'), + ); + } + } + + Future _cancelRecord(InboundRecord record) async { + final controller = _controller; + final id = record.id; + if (controller == null || id == null) { + if (mounted) { + SuperportToast.error(context, '취소할 대상을 찾을 수 없습니다.'); + } + return; + } + + try { + await controller.cancelTransaction(id); + if (!mounted) { + return; + } + SuperportToast.success(context, '취소 처리되었습니다.'); + } catch (error) { + if (!mounted) { + return; + } + SuperportToast.error( + context, + _failureMessage(error, '취소 처리에 실패했습니다. 잠시 후 다시 시도하세요.'), + ); + } + } + void _applyFilters() { setState(() { _query = _pendingQuery.trim(); _appliedDateRange = _pendingDateRange; - _appliedWarehouse = _pendingWarehouse; + _appliedWarehouseId = _pendingWarehouseId; + _appliedWarehouseLabel = _pendingWarehouseLabel; _appliedStatus = _pendingStatus; _appliedIncludes = {..._pendingIncludes}; _currentPage = 1; _refreshSelection(); }); _updateRoute(page: 1); + _fetchTransactions(page: 1); } void _resetFilters() { @@ -544,35 +937,43 @@ class _InboundPageState extends State { _query = ''; _pendingDateRange = null; _appliedDateRange = null; - _pendingWarehouse = null; - _appliedWarehouse = null; + _pendingWarehouseId = null; + _appliedWarehouseId = null; + _pendingWarehouseLabel = null; + _appliedWarehouseLabel = null; _pendingStatus = null; _appliedStatus = null; _sortField = _InboundSortField.processedAt; _sortAscending = false; - _pageSize = _pageSizeOptions.first; + _pageSize = InboundTableSpec.pageSizeOptions.first; _currentPage = 1; - _pendingIncludes = {..._includeOptions}; - _appliedIncludes = {..._includeOptions}; + _pendingIncludes = {...InboundTableSpec.defaultIncludeOptions}; + _appliedIncludes = {...InboundTableSpec.defaultIncludeOptions}; _includeController ..value.clear() ..value.addAll(_pendingIncludes); _refreshSelection(); }); - _updateRoute(page: 1, pageSize: _pageSizeOptions.first); + _updateRoute(page: 1, pageSize: InboundTableSpec.pageSizeOptions.first); + _fetchTransactions(page: 1); } bool get _hasAppliedFilters => _query.isNotEmpty || _appliedDateRange != null || - _appliedWarehouse != null || + _appliedWarehouseId != null || + (_appliedWarehouseLabel != null && _appliedWarehouseLabel!.isNotEmpty) || _appliedStatus != null || - !_setEquals(_appliedIncludes, _includeOptions.toSet()); + !_setEquals( + _appliedIncludes, + InboundTableSpec.defaultIncludeOptions.toSet(), + ); bool get _hasDirtyFilters => _pendingQuery.trim() != _query || !_isSameRange(_pendingDateRange, _appliedDateRange) || - _pendingWarehouse != _appliedWarehouse || + _pendingWarehouseId != _appliedWarehouseId || + _pendingWarehouseLabel != _appliedWarehouseLabel || _pendingStatus != _appliedStatus || !_setEquals(_pendingIncludes, _appliedIncludes); @@ -589,6 +990,7 @@ class _InboundPageState extends State { void _applyRouteParameters(Uri uri, {bool initialize = false}) { final params = uri.queryParameters; final query = params['q'] ?? ''; + final warehouseIdParam = int.tryParse(params['warehouse_id'] ?? ''); final warehouseParam = params['warehouse']; final statusParam = params['status']; final dateRange = _parseDateRange(params['start_date'], params['end_date']); @@ -600,34 +1002,43 @@ class _InboundPageState extends State { final pageSizeParam = int.tryParse(params['page_size'] ?? ''); final pageParam = int.tryParse(params['page'] ?? ''); - final warehouse = _warehouseOptions.contains(warehouseParam) + final warehouseId = warehouseIdParam; + final warehouseLabel = warehouseParam != null && warehouseParam.isNotEmpty ? warehouseParam : null; final status = _statusOptions.contains(statusParam) ? statusParam : null; final includes = includesParam == null || includesParam.isEmpty - ? {..._includeOptions} + ? {...InboundTableSpec.defaultIncludeOptions} : includesParam .split(',') .map((token) => token.trim()) - .where((token) => _includeOptions.contains(token)) + .where( + (token) => + InboundTableSpec.defaultIncludeOptions.contains(token), + ) .toSet(); final pageSize = - (pageSizeParam != null && _pageSizeOptions.contains(pageSizeParam)) + (pageSizeParam != null && + InboundTableSpec.pageSizeOptions.contains(pageSizeParam)) ? pageSizeParam - : _pageSizeOptions.first; + : InboundTableSpec.pageSizeOptions.first; final page = (pageParam != null && pageParam > 0) ? pageParam : 1; void assign() { _pendingQuery = query; _query = query; _searchController.text = query; - _pendingWarehouse = warehouse; - _appliedWarehouse = warehouse; + _pendingWarehouseId = warehouseId; + _appliedWarehouseId = warehouseId; + _pendingWarehouseLabel = warehouseLabel; + _appliedWarehouseLabel = warehouseLabel; _pendingStatus = status; _appliedStatus = status; _pendingDateRange = dateRange; _appliedDateRange = dateRange; - _pendingIncludes = includes.isEmpty ? {..._includeOptions} : includes; + _pendingIncludes = includes.isEmpty + ? {...InboundTableSpec.defaultIncludeOptions} + : includes; _appliedIncludes = {..._pendingIncludes}; _includeController ..value.clear() @@ -645,6 +1056,7 @@ class _InboundPageState extends State { } setState(assign); + _fetchTransactions(page: page); } void _goToPage(int page) { @@ -656,15 +1068,35 @@ class _InboundPageState extends State { _currentPage = target; }); _updateRoute(page: target); + _fetchTransactions(page: target); } void _refreshSelection() { final filtered = _filteredRecords; - if (_selectedRecord != null && !filtered.contains(_selectedRecord)) { - _selectedRecord = filtered.isEmpty ? null : filtered.first; - } else if (_selectedRecord == null && filtered.isNotEmpty) { - _selectedRecord = filtered.first; + if (filtered.isEmpty) { + _selectedRecord = null; + return; } + + final current = _selectedRecord; + if (current != null) { + InboundRecord? matched; + if (current.id != null) { + matched = filtered.firstWhere( + (element) => element.id == current.id, + orElse: () => filtered.first, + ); + } else { + matched = filtered.firstWhere( + (element) => element.transactionNumber == current.transactionNumber, + orElse: () => filtered.first, + ); + } + _selectedRecord = matched; + return; + } + + _selectedRecord = filtered.first; } void _updateRoute({int? page, int? pageSize}) { @@ -676,8 +1108,15 @@ class _InboundPageState extends State { if (_query.isNotEmpty) { params['q'] = _query; } - if (_appliedWarehouse != null && _appliedWarehouse!.isNotEmpty) { - params['warehouse'] = _appliedWarehouse!; + if (_appliedWarehouseId != null) { + params['warehouse_id'] = _appliedWarehouseId.toString(); + if (_appliedWarehouseLabel != null && + _appliedWarehouseLabel!.isNotEmpty) { + params['warehouse'] = _appliedWarehouseLabel!; + } + } else if (_appliedWarehouseLabel != null && + _appliedWarehouseLabel!.isNotEmpty) { + params['warehouse'] = _appliedWarehouseLabel!; } if (_appliedStatus != null && _appliedStatus!.isNotEmpty) { params['status'] = _appliedStatus!; @@ -687,7 +1126,10 @@ class _InboundPageState extends State { params['start_date'] = _formatDateParam(dateRange.start); params['end_date'] = _formatDateParam(dateRange.end); } - if (!_setEquals(_appliedIncludes, _includeOptions.toSet())) { + if (!_setEquals( + _appliedIncludes, + InboundTableSpec.defaultIncludeOptions.toSet(), + )) { params['include'] = _appliedIncludes.join(','); } final sortParam = _encodeSortField(_sortField); @@ -700,7 +1142,7 @@ class _InboundPageState extends State { if (targetPage > 1) { params['page'] = targetPage.toString(); } - if (targetPageSize != _pageSizeOptions.first) { + if (targetPageSize != InboundTableSpec.pageSizeOptions.first) { params['page_size'] = targetPageSize.toString(); } @@ -749,15 +1191,6 @@ class _InboundPageState extends State { return true; } - String _includeLabel(String value) { - switch (value) { - case 'lines': - return '라인 포함'; - default: - return value; - } - } - _InboundSortField? _sortFieldFromParam(String? value) { switch (value) { case 'warehouse': @@ -811,21 +1244,57 @@ class _InboundPageState extends State { final processedAt = ValueNotifier( initial?.processedAt ?? DateTime.now(), ); - final warehouseController = TextEditingController( - text: initial?.warehouse ?? _warehouseOptions.first, - ); + final initialWarehouse = initial?.raw?.warehouse; + final int? initialWarehouseId = + initialWarehouse?.id ?? initial?.warehouseId; + final String? initialWarehouseName = + initialWarehouse?.name ?? initial?.warehouse; + final String? initialWarehouseCode = initialWarehouse?.code; + InventoryWarehouseOption? warehouseSelection; + if (initialWarehouseId != null && initialWarehouseName != null) { + warehouseSelection = InventoryWarehouseOption( + id: initialWarehouseId, + code: initialWarehouseCode ?? initialWarehouseName, + name: initialWarehouseName, + ); + } final statusValue = ValueNotifier( initial?.status ?? _statusOptions.first, ); + InventoryEmployeeSuggestion? writerSelection; + final initialWriter = initial?.raw?.createdBy; + if (initialWriter != null) { + writerSelection = InventoryEmployeeSuggestion( + id: initialWriter.id, + employeeNo: initialWriter.employeeNo, + name: initialWriter.name, + ); + } else if (initial?.writerId != null && + (initial?.writer ?? '').isNotEmpty) { + writerSelection = InventoryEmployeeSuggestion( + id: initial!.writerId!, + employeeNo: initial.writer, + name: initial.writer, + ); + } + String writerLabel(InventoryEmployeeSuggestion? suggestion) { + if (suggestion == null) { + return ''; + } + return '${suggestion.name} (${suggestion.employeeNo})'; + } + final writerController = TextEditingController( - text: initial?.writer ?? '홍길동', + text: writerLabel(writerSelection), ); final remarkController = TextEditingController(text: initial?.remark ?? ''); final transactionNumberController = TextEditingController( text: initial?.transactionNumber ?? '저장 시 자동 생성', ); final transactionTypeValue = - initial?.transactionType ?? _inboundTransactionTypeId; + initial?.transactionType ?? + _transactionTypeLookup?.name ?? + _inboundTransactionTypeId; final transactionTypeController = TextEditingController( text: transactionTypeValue, ); @@ -845,16 +1314,32 @@ class _InboundPageState extends State { String? warehouseError; String? statusError; String? headerNotice; - void Function(VoidCallback fn)? refreshForm; + StateSetter? refreshForm; + StateSetter? refreshCancelAction; + StateSetter? refreshSaveAction; + var isSaving = false; InboundRecord? result; final navigator = Navigator.of(context); - void handleSubmit() { + void updateSaving(bool next) { + isSaving = next; + refreshForm?.call(() {}); + refreshCancelAction?.call(() {}); + refreshSaveAction?.call(() {}); + } + + Future handleSubmit() async { + if (isSaving) { + return; + } + final validationResult = _validateInboundForm( writerController: writerController, - warehouseValue: warehouseController.text, + writerSelection: writerSelection, + requireWriterSelection: initial == null, + warehouseSelection: warehouseSelection, statusValue: statusValue.value, drafts: drafts, lineErrors: lineErrors, @@ -870,33 +1355,152 @@ class _InboundPageState extends State { return; } - final items = drafts - .map( - (draft) => InboundLineItem( - product: draft.product.text.trim(), - manufacturer: draft.manufacturer.text.trim(), - unit: draft.unit.text.trim(), - quantity: int.tryParse(draft.quantity.text.trim()) ?? 0, - price: _parseCurrency(draft.price.text), - remark: draft.remark.text.trim(), + final controller = _controller; + if (controller == null) { + SuperportToast.error(context, '입고 컨트롤러를 찾을 수 없습니다.'); + return; + } + + final transactionTypeLookup = + _transactionTypeLookup ?? controller.transactionType; + if (transactionTypeLookup == null) { + SuperportToast.error(context, '입고 트랜잭션 유형 정보를 불러오지 못했습니다.'); + return; + } + + final statusItem = _statusLookup[statusValue.value]; + if (statusItem == null) { + SuperportToast.error(context, '선택한 상태 정보를 확인할 수 없습니다.'); + return; + } + + final warehouseId = warehouseSelection?.id ?? initialWarehouseId; + if (warehouseId == null) { + SuperportToast.error(context, '창고 정보를 확인할 수 없습니다.'); + return; + } + + final createdById = writerSelection?.id ?? initial?.writerId; + if (initial == null && createdById == null) { + SuperportToast.error(context, '작성자 정보를 확인할 수 없습니다.'); + return; + } + + final remarkText = remarkController.text.trim(); + final remarkValue = remarkText.isEmpty ? null : remarkText; + + final transactionId = initial?.id; + final initialRecord = initial; + + final lineDrafts = []; + for (var index = 0; index < drafts.length; index++) { + final draft = drafts[index]; + final productId = draft.productId ?? draft.suggestion?.id; + if (productId == null) { + continue; + } + final quantity = int.tryParse(draft.quantity.text.trim()) ?? 0; + final unitPrice = _parseCurrency(draft.price.text); + final noteText = draft.remark.text.trim(); + lineDrafts.add( + TransactionLineDraft( + id: draft.lineId, + lineNo: index + 1, + productId: productId, + quantity: quantity, + unitPrice: unitPrice, + note: noteText.isEmpty ? null : noteText, + ), + ); + } + + if (lineDrafts.isEmpty) { + SuperportToast.error(context, '최소 1개 이상의 품목을 선택하세요.'); + return; + } + + try { + updateSaving(true); + + if (initialRecord != null) { + if (transactionId == null) { + updateSaving(false); + SuperportToast.error(context, '수정 대상 ID를 확인할 수 없습니다.'); + return; + } + final updated = await controller.updateTransaction( + transactionId, + StockTransactionUpdateInput( + transactionStatusId: statusItem.id, + note: remarkValue, ), - ) - .toList(); - items.sort((a, b) => a.product.compareTo(b.product)); - result = InboundRecord( - number: initial?.number ?? _generateInboundNumber(processedAt.value), - transactionNumber: - initial?.transactionNumber ?? - _generateTransactionNumber(processedAt.value), - transactionType: transactionTypeValue, - processedAt: processedAt.value, - warehouse: warehouseController.text, - status: statusValue.value, - writer: writerController.text.trim(), - remark: remarkController.text.trim(), - items: items, - ); - navigator.pop(); + refreshAfter: false, + ); + result = updated; + final currentLines = + initialRecord.raw?.lines ?? const []; + final plan = _detailSyncService.buildLinePlan( + drafts: lineDrafts, + currentLines: currentLines, + ); + if (plan.hasChanges) { + await controller.syncTransactionLines(transactionId, plan); + } + await controller.refresh(); + updateSaving(false); + if (!mounted) { + return; + } + SuperportToast.success(context, '입고 정보가 수정되었습니다.'); + navigator.pop(); + return; + } + + if (createdById == null) { + updateSaving(false); + SuperportToast.error(context, '작성자 선택이 필요합니다.'); + return; + } + + final createLines = lineDrafts + .map( + (draft) => TransactionLineCreateInput( + lineNo: draft.lineNo, + productId: draft.productId, + quantity: draft.quantity, + unitPrice: draft.unitPrice, + note: draft.note, + ), + ) + .toList(growable: false); + final created = await controller.createTransaction( + StockTransactionCreateInput( + transactionTypeId: transactionTypeLookup.id, + transactionStatusId: statusItem.id, + warehouseId: warehouseId, + transactionDate: processedAt.value, + createdById: createdById, + note: remarkValue, + lines: createLines, + ), + ); + result = created; + updateSaving(false); + if (!mounted) { + return; + } + SuperportToast.success(context, '입고가 등록되었습니다.'); + navigator.pop(); + } catch (error) { + updateSaving(false); + if (!mounted) { + return; + } + SuperportToast.error( + context, + _failureMessage(error, '저장 중 오류가 발생했습니다. 잠시 후 다시 시도하세요.'), + ); + } } await showSuperportDialog( @@ -906,11 +1510,30 @@ class _InboundPageState extends State { constraints: const BoxConstraints(maxWidth: 860, maxHeight: 720), onSubmit: handleSubmit, actions: [ - ShadButton.ghost( - onPressed: () => navigator.pop(), - child: const Text('취소'), + StatefulBuilder( + builder: (context, setState) { + refreshCancelAction = setState; + return ShadButton.ghost( + onPressed: isSaving ? null : () => navigator.pop(), + child: const Text('취소'), + ); + }, + ), + StatefulBuilder( + builder: (context, setState) { + refreshSaveAction = setState; + return ShadButton( + onPressed: isSaving ? null : () => handleSubmit(), + child: isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('저장'), + ); + }, ), - ShadButton(onPressed: handleSubmit, child: const Text('저장')), ], body: StatefulBuilder( builder: (context, setState) { @@ -934,8 +1557,9 @@ class _InboundPageState extends State { child: SuperportDatePickerButton( value: processedAt.value, dateFormat: _dateFormatter, - firstDate: DateTime(2020), - lastDate: DateTime(2030), + firstDate: InboundTableSpec.dateRangeFirstDate, + lastDate: InboundTableSpec.dateRangeLastDate, + enabled: initial == null, onChanged: (date) { processedAt.value = date; setState(() {}); @@ -949,28 +1573,17 @@ class _InboundPageState extends State { label: '창고', required: true, errorText: warehouseError, - child: ShadSelect( - initialValue: warehouseController.text, - selectedOptionBuilder: (context, value) => - Text(value), - onChanged: (value) { - if (value != null) { - warehouseController.text = value; - setState(() { - if (warehouseError != null) { - warehouseError = null; - } - }); - } + child: InventoryWarehouseSelectField( + initialWarehouseId: initialWarehouseId, + enabled: initial == null, + onChanged: (option) { + warehouseSelection = option; + setState(() { + if (warehouseError != null) { + warehouseError = null; + } + }); }, - options: _warehouseOptions - .map( - (option) => ShadOption( - value: option, - child: Text(option), - ), - ) - .toList(), ), ), ), @@ -1035,16 +1648,35 @@ class _InboundPageState extends State { label: '작성자', required: true, errorText: writerError, - child: ShadInput( + child: InventoryEmployeeAutocompleteField( controller: writerController, - readOnly: initial != null, - onChanged: (_) { - if (writerError != null && initial == null) { + initialSuggestion: writerSelection, + enabled: initial == null, + onSuggestionSelected: (suggestion) { + writerSelection = suggestion; + if (writerError != null) { setState(() { writerError = null; }); } }, + onChanged: () { + if (initial == null) { + final currentText = writerController.text.trim(); + final selectedLabel = writerLabel( + writerSelection, + ); + if (currentText.isEmpty || + currentText != selectedLabel) { + writerSelection = null; + } + if (writerError != null) { + setState(() { + writerError = null; + }); + } + } + }, ), ), ), @@ -1166,7 +1798,6 @@ class _InboundPageState extends State { for (final draft in drafts) { draft.dispose(); } - warehouseController.dispose(); statusValue.dispose(); writerController.dispose(); remarkController.dispose(); @@ -1176,33 +1807,6 @@ class _InboundPageState extends State { return result; } - - static String _generateInboundNumber(DateTime date) { - final stamp = DateFormat('yyyyMMdd-HHmmss').format(date); - return 'IN-$stamp'; - } - - static String _generateTransactionNumber(DateTime date) { - final stamp = DateFormat('yyyyMMdd-HHmmss').format(date); - return 'TX-$stamp'; - } - - static const _tableHeaders = [ - '번호', - '처리일자', - '창고', - '트랜잭션번호', - '제품', - '제조사', - '단위', - '수량', - '단가', - '상태', - '작성자', - '품목수', - '총수량', - '비고', - ]; } class _DetailCard extends StatelessWidget { @@ -1211,12 +1815,34 @@ class _DetailCard extends StatelessWidget { required this.dateFormatter, required this.currencyFormatter, required this.onEdit, + this.transitionsEnabled = true, + this.onSubmit, + this.onComplete, + this.onApprove, + this.onReject, + this.onCancel, + this.canSubmit = true, + this.canComplete = true, + this.canApprove = true, + this.canReject = true, + this.canCancel = true, }); final InboundRecord record; final DateFormat dateFormatter; final NumberFormat currencyFormatter; final VoidCallback onEdit; + final bool transitionsEnabled; + final Future Function()? onSubmit; + final Future Function()? onComplete; + final Future Function()? onApprove; + final Future Function()? onReject; + final Future Function()? onCancel; + final bool canSubmit; + final bool canComplete; + final bool canApprove; + final bool canReject; + final bool canCancel; @override Widget build(BuildContext context) { @@ -1227,10 +1853,60 @@ class _DetailCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('선택된 입고 상세', style: theme.textTheme.h3), - ShadButton.outline( - leading: const Icon(lucide.LucideIcons.pencil, size: 16), - onPressed: onEdit, - child: const Text('수정'), + Wrap( + spacing: 8, + children: [ + if (onSubmit != null) + ShadButton.outline( + onPressed: canSubmit + ? () { + onSubmit?.call(); + } + : null, + child: const Text('상신'), + ), + if (onApprove != null) + ShadButton.outline( + onPressed: canApprove + ? () { + onApprove?.call(); + } + : null, + child: const Text('승인'), + ), + if (onReject != null) + ShadButton.outline( + onPressed: canReject + ? () { + onReject?.call(); + } + : null, + child: const Text('반려'), + ), + if (onCancel != null) + ShadButton.outline( + onPressed: canCancel + ? () { + onCancel?.call(); + } + : null, + child: const Text('취소'), + ), + if (onComplete != null) + ShadButton.outline( + onPressed: canComplete + ? () { + onComplete?.call(); + } + : null, + child: const Text('완료 처리'), + ), + ShadButton.outline( + leading: const Icon(lucide.LucideIcons.pencil, size: 16), + onPressed: onEdit, + child: const Text('수정'), + ), + ], ), ], ), @@ -1241,6 +1917,15 @@ class _DetailCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (!transitionsEnabled) ...[ + ShadBadge.outline( + child: Text( + '재고 상태 전이가 비활성화된 상태입니다.', + style: theme.textTheme.small, + ), + ), + const SizedBox(height: 16), + ], Wrap( spacing: 12, runSpacing: 12, @@ -1371,6 +2056,18 @@ class _InboundMobileList extends StatelessWidget { required this.onSelect, required this.dateFormatter, required this.currencyFormatter, + this.transitionsEnabled = true, + this.onSubmit, + this.onComplete, + this.onApprove, + this.onReject, + this.onCancel, + this.canSubmit, + this.canComplete, + this.canApprove, + this.canReject, + this.canCancel, + this.isProcessing, }); final List records; @@ -1378,6 +2075,18 @@ class _InboundMobileList extends StatelessWidget { final ValueChanged onSelect; final DateFormat dateFormatter; final NumberFormat currencyFormatter; + final bool transitionsEnabled; + final Future Function(InboundRecord record)? onSubmit; + final Future Function(InboundRecord record)? onComplete; + final Future Function(InboundRecord record)? onApprove; + final Future Function(InboundRecord record)? onReject; + final Future Function(InboundRecord record)? onCancel; + final bool Function(InboundRecord record)? canSubmit; + final bool Function(InboundRecord record)? canComplete; + final bool Function(InboundRecord record)? canApprove; + final bool Function(InboundRecord record)? canReject; + final bool Function(InboundRecord record)? canCancel; + final bool Function(InboundRecord record)? isProcessing; @override Widget build(BuildContext context) { @@ -1389,7 +2098,7 @@ class _InboundMobileList extends StatelessWidget { itemBuilder: (context, index) { final record = records[index]; final isSelected = selected?.number == record.number; - final primaryItem = record.items.first; + final primaryItem = record.items.isNotEmpty ? record.items.first : null; return AnimatedContainer( duration: const Duration(milliseconds: 180), @@ -1461,16 +2170,81 @@ class _InboundMobileList extends StatelessWidget { icon: lucide.LucideIcons.chartBar, label: '총 ${record.totalQuantity}ea', ), - _MobileMetaChip( - icon: lucide.LucideIcons.coins, - label: currencyFormatter.format(primaryItem.price), - ), + if (primaryItem != null) + _MobileMetaChip( + icon: lucide.LucideIcons.coins, + label: currencyFormatter.format(primaryItem.price), + ), ], ), if (record.remark.isNotEmpty) ...[ const SizedBox(height: 12), Text(record.remark, style: theme.textTheme.muted), ], + if (!transitionsEnabled) ...[ + const SizedBox(height: 16), + Text( + '재고 상태 전이가 비활성화되어 있습니다.', + style: theme.textTheme.small, + ), + ] else if (_hasActions(record)) ...[ + const SizedBox(height: 16), + Wrap( + spacing: 8, + children: [ + if (onSubmit != null && + (canSubmit?.call(record) ?? false)) + ShadButton.outline( + onPressed: _isProcessing(record) + ? null + : () { + onSubmit?.call(record); + }, + child: const Text('상신'), + ), + if (onApprove != null && + (canApprove?.call(record) ?? false)) + ShadButton.outline( + onPressed: _isProcessing(record) + ? null + : () { + onApprove?.call(record); + }, + child: const Text('승인'), + ), + if (onReject != null && + (canReject?.call(record) ?? false)) + ShadButton.outline( + onPressed: _isProcessing(record) + ? null + : () { + onReject?.call(record); + }, + child: const Text('반려'), + ), + if (onCancel != null && + (canCancel?.call(record) ?? false)) + ShadButton.outline( + onPressed: _isProcessing(record) + ? null + : () { + onCancel?.call(record); + }, + child: const Text('취소'), + ), + if (onComplete != null && + (canComplete?.call(record) ?? false)) + ShadButton.outline( + onPressed: _isProcessing(record) + ? null + : () { + onComplete?.call(record); + }, + child: const Text('완료 처리'), + ), + ], + ), + ], ], ), ), @@ -1480,6 +2254,28 @@ class _InboundMobileList extends StatelessWidget { }, ); } + + bool _hasActions(InboundRecord record) { + final submitAllowed = + onSubmit != null && (canSubmit?.call(record) ?? false); + final completeAllowed = + onComplete != null && (canComplete?.call(record) ?? false); + final approveAllowed = + onApprove != null && (canApprove?.call(record) ?? false); + final rejectAllowed = + onReject != null && (canReject?.call(record) ?? false); + final cancelAllowed = + onCancel != null && (canCancel?.call(record) ?? false); + return submitAllowed || + completeAllowed || + approveAllowed || + rejectAllowed || + cancelAllowed; + } + + bool _isProcessing(InboundRecord record) { + return isProcessing?.call(record) ?? false; + } } class _MobileMetaChip extends StatelessWidget { @@ -1548,7 +2344,9 @@ class _LineItemFieldErrors { /// 입고 등록 폼 내용을 검증하고 오류 메시지를 반환한다. _InboundFormValidation _validateInboundForm({ required TextEditingController writerController, - required String warehouseValue, + required InventoryEmployeeSuggestion? writerSelection, + required bool requireWriterSelection, + required InventoryWarehouseOption? warehouseSelection, required String statusValue, required List<_LineItemDraft> drafts, required Map<_LineItemDraft, _LineItemFieldErrors> lineErrors, @@ -1559,12 +2357,18 @@ _InboundFormValidation _validateInboundForm({ String? statusError; String? headerNotice; - if (writerController.text.trim().isEmpty) { + final writerText = writerController.text.trim(); + if (writerText.isEmpty) { writerError = '작성자를 입력하세요.'; isValid = false; } - if (warehouseValue.trim().isEmpty) { + if (requireWriterSelection && writerSelection == null) { + writerError = '작성자는 자동완성에서 선택하세요.'; + isValid = false; + } + + if (warehouseSelection == null) { warehouseError = '창고를 선택하세요.'; isValid = false; } @@ -1585,13 +2389,13 @@ _InboundFormValidation _validateInboundForm({ errors.product = '제품을 입력하세요.'; hasLineError = true; isValid = false; - } else if (draft.catalogMatch == null) { + } else if (draft.suggestion == null) { errors.product = '제품은 목록에서 선택하세요.'; hasLineError = true; isValid = false; } - final productKey = (draft.catalogMatch?.code ?? productText.toLowerCase()); + final productKey = (draft.suggestion?.code ?? productText.toLowerCase()); if (productKey.isNotEmpty && !seenProductKeys.add(productKey)) { errors.product = '동일 제품이 중복되었습니다.'; hasLineError = true; @@ -1707,8 +2511,10 @@ class _LineItemRow extends StatelessWidget { productFocusNode: draft.productFocus, manufacturerController: draft.manufacturer, unitController: draft.unit, - onCatalogMatched: (catalog) { - draft.catalogMatch = catalog; + initialSuggestion: draft.suggestion, + onSuggestionSelected: (suggestion) { + draft.suggestion = suggestion; + draft.productId = suggestion?.id; onFieldChanged(_LineItemField.product); }, onChanged: () => onFieldChanged(_LineItemField.product), @@ -1803,6 +2609,8 @@ class _LineItemDraft { required this.quantity, required this.price, required this.remark, + this.lineId, + this.productId, }); final TextEditingController product; @@ -1812,7 +2620,9 @@ class _LineItemDraft { final TextEditingController quantity; final TextEditingController price; final TextEditingController remark; - InventoryProductCatalogItem? catalogMatch; + final int? lineId; + int? productId; + InventoryProductSuggestion? suggestion; factory _LineItemDraft.empty() { return _LineItemDraft._( @@ -1823,6 +2633,8 @@ class _LineItemDraft { quantity: TextEditingController(text: '1'), price: TextEditingController(text: '0'), remark: TextEditingController(), + lineId: null, + productId: null, ); } @@ -1835,8 +2647,16 @@ class _LineItemDraft { quantity: TextEditingController(text: '${item.quantity}'), price: TextEditingController(text: item.price.toStringAsFixed(0)), remark: TextEditingController(text: item.remark), + lineId: item.id, + productId: item.productId, + ); + draft.suggestion = InventoryProductSuggestion( + id: item.productId, + code: item.productCode, + name: item.product, + vendorName: item.manufacturer, + unitName: item.unit, ); - draft.catalogMatch = InventoryProductCatalog.match(item.product); return draft; } @@ -1850,130 +2670,3 @@ class _LineItemDraft { remark.dispose(); } } - -class InboundRecord { - InboundRecord({ - required this.number, - required this.transactionNumber, - required this.transactionType, - required this.processedAt, - required this.warehouse, - required this.status, - required this.writer, - required this.remark, - required this.items, - }); - - final String number; - final String transactionNumber; - final String transactionType; - final DateTime processedAt; - final String warehouse; - final String status; - final String writer; - final String remark; - final List items; - - int get itemCount => items.length; - int get totalQuantity => - items.fold(0, (sum, item) => sum + item.quantity); - double get totalAmount => - items.fold(0, (sum, item) => sum + (item.price * item.quantity)); -} - -class InboundLineItem { - InboundLineItem({ - required this.product, - required this.manufacturer, - required this.unit, - required this.quantity, - required this.price, - required this.remark, - }); - - final String product; - final String manufacturer; - final String unit; - final int quantity; - final double price; - final String remark; -} - -final List _mockRecords = [ - InboundRecord( - number: 'IN-20240301-001', - transactionNumber: 'TX-20240301-001', - transactionType: _inboundTransactionTypeId, - processedAt: DateTime(2024, 3, 1), - warehouse: '서울 1창고', - status: '작성중', - writer: '홍길동', - remark: '-', - items: [ - InboundLineItem( - product: 'XR-5000', - manufacturer: '슈퍼벤더', - unit: 'EA', - quantity: 40, - price: 120000, - remark: '', - ), - InboundLineItem( - product: 'XR-5001', - manufacturer: '슈퍼벤더', - unit: 'EA', - quantity: 60, - price: 98000, - remark: '', - ), - ], - ), - InboundRecord( - number: 'IN-20240305-002', - transactionNumber: 'TX-20240305-010', - transactionType: _inboundTransactionTypeId, - processedAt: DateTime(2024, 3, 5), - warehouse: '부산 센터', - status: '승인대기', - writer: '김담당', - remark: '긴급 입고', - items: [ - InboundLineItem( - product: 'Eco-200', - manufacturer: '그린텍', - unit: 'EA', - quantity: 25, - price: 145000, - remark: 'QC 필요', - ), - InboundLineItem( - product: 'Eco-200B', - manufacturer: '그린텍', - unit: 'EA', - quantity: 10, - price: 160000, - remark: '', - ), - ], - ), - InboundRecord( - number: 'IN-20240310-003', - transactionNumber: 'TX-20240310-004', - transactionType: _inboundTransactionTypeId, - processedAt: DateTime(2024, 3, 10), - warehouse: '대전 물류', - status: '승인완료', - writer: '최검수', - remark: '완료', - items: [ - InboundLineItem( - product: 'Delta-One', - manufacturer: '델타', - unit: 'SET', - quantity: 8, - price: 450000, - remark: '설치 일정 확인', - ), - ], - ), -]; diff --git a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart index 570bf81..8aba167 100644 --- a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart +++ b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/feedback.dart'; @@ -10,9 +12,25 @@ import 'package:superport_v2/widgets/components/filter_bar.dart'; import 'package:superport_v2/widgets/components/form_field.dart'; import 'package:superport_v2/widgets/components/superport_dialog.dart'; import 'package:superport_v2/widgets/components/superport_date_picker.dart'; -import 'package:superport_v2/features/inventory/shared/catalogs.dart'; import 'package:superport_v2/features/inventory/shared/widgets/product_autocomplete_field.dart'; +import 'package:superport_v2/features/inventory/shared/widgets/employee_autocomplete_field.dart'; +import 'package:superport_v2/features/inventory/shared/widgets/customer_multi_select_field.dart'; +import 'package:superport_v2/features/inventory/shared/widgets/warehouse_select_field.dart'; +import 'package:superport_v2/core/config/environment.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/inventory/outbound/presentation/controllers/outbound_controller.dart'; +import 'package:superport_v2/features/inventory/outbound/presentation/models/outbound_record.dart'; +import 'package:superport_v2/features/inventory/outbound/presentation/specs/outbound_table_spec.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/features/inventory/transactions/presentation/services/transaction_detail_sync_service.dart'; +import 'package:superport_v2/features/masters/customer/domain/entities/customer.dart'; +import 'package:superport_v2/features/masters/customer/domain/repositories/customer_repository.dart'; +import '../../../lookups/domain/entities/lookup_item.dart'; +import '../../../lookups/domain/repositories/inventory_lookup_repository.dart'; const String _outboundTransactionTypeId = '출고'; @@ -35,43 +53,207 @@ class _OutboundPageState extends State { symbol: '₩', decimalDigits: 0, ); + final TransactionDetailSyncService _detailSyncService = + const TransactionDetailSyncService(); String _query = ''; String _pendingQuery = ''; - String? _appliedWarehouse; - String? _pendingWarehouse; + int? _appliedWarehouseId; + int? _pendingWarehouseId; + String? _appliedWarehouseLabel; + String? _pendingWarehouseLabel; String? _appliedStatus; String? _pendingStatus; - String? _appliedCustomer; - String? _pendingCustomer; + CustomerFilterOption _appliedCustomer = CustomerFilterOption.all; + CustomerFilterOption _pendingCustomer = CustomerFilterOption.all; DateTimeRange? _appliedDateRange; DateTimeRange? _pendingDateRange; - final List _records = _mockOutboundRecords; + List _records = []; OutboundRecord? _selectedRecord; - static const _statusOptions = ['작성중', '출고대기', '출고완료']; - static const _warehouseOptions = ['서울 1창고', '부산 센터', '대전 물류']; - static final List _customerOptions = InventoryCustomerCatalog.items - .map((item) => item.name) - .toList(); - static const _pageSizeOptions = [10, 20, 50]; - static const _includeOptions = ['lines', 'customers']; + PaginatedResult? _result; + bool _isLoading = false; + String? _errorMessage; + Set _processingTransactionIds = {}; + + late List _statusOptions; + final Map _statusLookup = {}; + LookupItem? _transactionTypeLookup; + List _customerOptions = const [ + CustomerFilterOption.all, + ]; + bool _isLoadingCustomers = false; + String? _customerError; int _currentPage = 1; - int _pageSize = _pageSizeOptions.first; + int _pageSize = OutboundTableSpec.pageSizeOptions.first; _OutboundSortField _sortField = _OutboundSortField.processedAt; bool _sortAscending = false; - Set _appliedIncludes = {..._includeOptions}; - Set _pendingIncludes = {..._includeOptions}; + OutboundController? _controller; + Set _appliedIncludes = {...OutboundTableSpec.defaultIncludeOptions}; + Set _pendingIncludes = {...OutboundTableSpec.defaultIncludeOptions}; late final ShadSelectController _includeController; + bool get _transitionsEnabled => + Environment.flag('FEATURE_STOCK_TRANSITIONS_ENABLED', defaultValue: true); + @override void initState() { super.initState(); _includeController = ShadSelectController( initialValue: _pendingIncludes.toSet(), ); + _statusOptions = List.from(OutboundTableSpec.fallbackStatusOptions); + _controller = _createController(); + _controller?.addListener(_handleControllerChanged); _applyRouteParameters(widget.routeUri, initialize: true); + _initializeController(); + _loadCustomerOptions(); + } + + OutboundController? _createController() { + final getIt = GetIt.I; + if (!getIt.isRegistered() || + !getIt.isRegistered() || + !getIt.isRegistered() || + !getIt.isRegistered()) { + return null; + } + return OutboundController( + transactionRepository: getIt(), + lineRepository: getIt(), + customerRepository: getIt(), + lookupRepository: getIt(), + fallbackStatusOptions: OutboundTableSpec.fallbackStatusOptions, + transactionTypeKeywords: OutboundTableSpec.transactionTypeKeywords, + ); + } + + void _initializeController() { + final controller = _controller; + if (controller == null) { + return; + } + Future.microtask(() async { + await controller.loadStatusOptions(); + final hasType = await controller.resolveTransactionType(); + if (!mounted) { + return; + } + if (hasType) { + await _fetchTransactions(page: _currentPage); + } + }); + } + + Future _loadCustomerOptions() async { + final getIt = GetIt.I; + if (!getIt.isRegistered()) { + setState(() { + _customerOptions = const [CustomerFilterOption.all]; + _customerError = null; + }); + return; + } + + setState(() { + _isLoadingCustomers = true; + _customerError = null; + }); + + try { + final repository = getIt(); + final result = await repository.list( + page: 1, + pageSize: 100, + isActive: true, + ); + if (!mounted) { + return; + } + final seen = {CustomerFilterOption.all.cacheKey}; + final options = [CustomerFilterOption.all]; + for (final customer in result.items) { + final option = CustomerFilterOption.fromCustomer(customer); + if (seen.add(option.cacheKey)) { + options.add(option); + } + } + setState(() { + _customerOptions = options; + _appliedCustomer = _resolveCustomerOption(_appliedCustomer, options); + _pendingCustomer = _resolveCustomerOption(_pendingCustomer, options); + }); + } catch (error) { + if (mounted) { + final message = _failureMessage( + error, + '고객사 목록을 불러오지 못했습니다. 잠시 후 다시 시도하세요.', + ); + setState(() { + _customerError = message; + _customerOptions = const [CustomerFilterOption.all]; + _appliedCustomer = CustomerFilterOption.all; + _pendingCustomer = CustomerFilterOption.all; + }); + SuperportToast.error(context, message); + } + } finally { + if (mounted) { + setState(() { + _isLoadingCustomers = false; + }); + } + } + } + + void _handleControllerChanged() { + if (!mounted) { + return; + } + final controller = _controller; + if (controller == null) { + return; + } + final statusSource = controller.statusOptions.isEmpty + ? controller.fallbackStatusOptions + : controller.statusOptions; + final resolvedStatusOptions = List.from(statusSource); + final resolvedLookup = Map.from( + controller.statusLookup, + ); + + var pendingStatus = _pendingStatus; + if (pendingStatus != null && + !resolvedStatusOptions.contains(pendingStatus)) { + pendingStatus = null; + } + var appliedStatus = _appliedStatus; + if (appliedStatus != null && + !resolvedStatusOptions.contains(appliedStatus)) { + appliedStatus = null; + } + + setState(() { + _statusOptions = resolvedStatusOptions.isEmpty + ? List.from(OutboundTableSpec.fallbackStatusOptions) + : resolvedStatusOptions; + _statusLookup + ..clear() + ..addAll(resolvedLookup); + _pendingStatus = pendingStatus; + _appliedStatus = appliedStatus; + _result = controller.result; + _records = controller.records.toList(); + _isLoading = controller.isLoading; + _errorMessage = controller.errorMessage; + _processingTransactionIds = Set.from( + controller.processingTransactionIds, + ); + _transactionTypeLookup = + controller.transactionType ?? _transactionTypeLookup; + _refreshSelection(); + }); } @override @@ -84,23 +266,62 @@ class _OutboundPageState extends State { @override void dispose() { + _controller?.removeListener(_handleControllerChanged); + _controller?.dispose(); _searchController.dispose(); _includeController.dispose(); super.dispose(); } + Future _fetchTransactions({int? page}) async { + final controller = _controller; + final lookup = controller?.transactionType ?? _transactionTypeLookup; + if (controller == null || lookup == null) { + return; + } + + final targetPage = page ?? _currentPage; + final includes = _appliedIncludes.isEmpty + ? OutboundTableSpec.defaultIncludeOptions.toList(growable: false) + : _appliedIncludes.toList(growable: false); + final statusItem = _appliedStatus == null + ? null + : _statusLookup[_appliedStatus!]; + + setState(() { + _currentPage = targetPage; + _transactionTypeLookup = lookup; + }); + + final filter = StockTransactionListFilter( + page: targetPage, + pageSize: _pageSize, + query: _query.isEmpty ? null : _query, + transactionTypeId: lookup.id, + transactionStatusId: statusItem != null && statusItem.id > 0 + ? statusItem.id + : null, + customerId: _appliedCustomer.id, + from: _appliedDateRange?.start, + to: _appliedDateRange?.end, + include: includes, + ); + + await controller.fetchTransactions(filter: filter); + } + @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); final filtered = _filteredRecords; - final totalPages = _calculateTotalPages(filtered.length); + final totalItems = _result?.total ?? filtered.length; + final totalPages = _calculateTotalPages(totalItems); final int currentPage = totalPages <= 1 ? 1 : (_currentPage < 1 ? 1 : (_currentPage > totalPages ? totalPages : _currentPage)); - final startIndex = (currentPage - 1) * _pageSize; - final visibleRecords = filtered.skip(startIndex).take(_pageSize).toList(); + final visibleRecords = filtered; return AppLayout( title: '출고 관리', @@ -112,7 +333,7 @@ class _OutboundPageState extends State { ], actions: [ PermissionGate( - resource: '/inventory/outbound', + resource: PermissionResources.stockTransactions, action: PermissionAction.create, child: ShadButton( leading: const Icon(lucide.LucideIcons.plus, size: 16), @@ -121,7 +342,7 @@ class _OutboundPageState extends State { ), ), PermissionGate( - resource: '/inventory/outbound', + resource: PermissionResources.stockTransactions, action: PermissionAction.edit, child: ShadButton.outline( leading: const Icon(lucide.LucideIcons.pencil, size: 16), @@ -144,7 +365,7 @@ class _OutboundPageState extends State { width: 260, child: ShadInput( controller: _searchController, - placeholder: const Text('트랜잭션번호, 작성자, 제품, 고객사 검색'), + placeholder: const Text(OutboundTableSpec.searchPlaceholder), leading: const Icon(lucide.LucideIcons.search, size: 16), onChanged: (_) { setState(() { @@ -160,22 +381,24 @@ class _OutboundPageState extends State { value: _pendingDateRange, dateFormat: _dateFormatter, onChanged: (range) => setState(() => _pendingDateRange = range), - firstDate: DateTime(2020), - lastDate: DateTime(2030), + firstDate: OutboundTableSpec.dateRangeFirstDate, + lastDate: OutboundTableSpec.dateRangeLastDate, ), ), SizedBox( width: 200, - child: ShadSelect( - key: ValueKey(_pendingWarehouse ?? 'all'), - initialValue: _pendingWarehouse, - selectedOptionBuilder: (_, value) => Text(value ?? '전체 창고'), - onChanged: (value) => setState(() => _pendingWarehouse = value), - options: [ - const ShadOption(value: null, child: Text('전체 창고')), - for (final option in _warehouseOptions) - ShadOption(value: option, child: Text(option)), - ], + child: InventoryWarehouseSelectField( + key: ValueKey(_pendingWarehouseId ?? 'all'), + initialWarehouseId: _pendingWarehouseId, + includeAllOption: true, + allLabel: OutboundTableSpec.allWarehouseLabel, + placeholder: const Text(OutboundTableSpec.allWarehouseLabel), + onChanged: (option) { + setState(() { + _pendingWarehouseId = option?.id; + _pendingWarehouseLabel = option?.name; + }); + }, ), ), SizedBox( @@ -183,10 +406,14 @@ class _OutboundPageState extends State { child: ShadSelect( key: ValueKey(_pendingStatus ?? 'all'), initialValue: _pendingStatus, - selectedOptionBuilder: (_, value) => Text(value ?? '전체 상태'), + selectedOptionBuilder: (_, value) => + Text(value ?? OutboundTableSpec.allStatusLabel), onChanged: (value) => setState(() => _pendingStatus = value), options: [ - const ShadOption(value: null, child: Text('전체 상태')), + const ShadOption( + value: null, + child: Text(OutboundTableSpec.allStatusLabel), + ), for (final option in _statusOptions) ShadOption(value: option, child: Text(option)), ], @@ -194,15 +421,30 @@ class _OutboundPageState extends State { ), SizedBox( width: 220, - child: ShadSelect( - key: ValueKey(_pendingCustomer ?? 'all'), + child: ShadSelect( + key: ValueKey(_pendingCustomer.cacheKey), initialValue: _pendingCustomer, - selectedOptionBuilder: (_, value) => Text(value ?? '전체 고객사'), - onChanged: (value) => setState(() => _pendingCustomer = value), + selectedOptionBuilder: (context, value) { + final theme = ShadTheme.of(context); + if (_customerError != null) { + return Text( + _customerError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ); + } + return Text(value.label); + }, + onChanged: _isLoadingCustomers + ? null + : (value) { + if (value == null) return; + setState(() => _pendingCustomer = value); + }, options: [ - const ShadOption(value: null, child: Text('전체 고객사')), for (final option in _customerOptions) - ShadOption(value: option, child: Text(option)), + ShadOption(value: option, child: Text(option.label)), ], ), ), @@ -234,7 +476,7 @@ class _OutboundPageState extends State { initialValues: _pendingIncludes, selectedOptionsBuilder: (context, values) { if (values.isEmpty) { - return const Text('Include 없음'); + return const Text(OutboundTableSpec.includeEmptyLabel); } return Wrap( spacing: 6, @@ -247,7 +489,7 @@ class _OutboundPageState extends State { horizontal: 8, vertical: 4, ), - child: Text(_includeLabel(value)), + child: Text(OutboundTableSpec.includeLabel(value)), ), ), ], @@ -259,8 +501,11 @@ class _OutboundPageState extends State { }); }, options: [ - for (final option in _includeOptions) - ShadOption(value: option, child: Text(_includeLabel(option))), + for (final option in OutboundTableSpec.defaultIncludeOptions) + ShadOption( + value: option, + child: Text(OutboundTableSpec.includeLabel(option)), + ), ], ), ), @@ -285,7 +530,7 @@ class _OutboundPageState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('출고 내역', style: theme.textTheme.h3), - Text('${filtered.length}건', style: theme.textTheme.muted), + Text('$totalItems건', style: theme.textTheme.muted), ], ), child: Column( @@ -293,15 +538,17 @@ class _OutboundPageState extends State { children: [ SizedBox( height: 420, - child: filtered.isEmpty + child: _isLoading && filtered.isEmpty + ? const Center(child: CircularProgressIndicator()) + : filtered.isEmpty ? Center( child: Text( - '조건에 맞는 출고 내역이 없습니다.', + _errorMessage ?? '조건에 맞는 출고 내역이 없습니다.', style: theme.textTheme.muted, ), ) : ShadTable.list( - header: _tableHeaders + header: OutboundTableSpec.headers .map( (header) => ShadTableCell.header(child: Text(header)), @@ -322,9 +569,12 @@ class _OutboundPageState extends State { ], ], columnSpanExtent: (index) => - const FixedTableSpanExtent(140), - rowSpanExtent: (index) => - const FixedTableSpanExtent(56), + const FixedTableSpanExtent( + OutboundTableSpec.columnSpanWidth, + ), + rowSpanExtent: (index) => const FixedTableSpanExtent( + OutboundTableSpec.rowSpanHeight, + ), onRowTap: (rowIndex) { setState(() { _selectedRecord = visibleRecords[rowIndex]; @@ -351,9 +601,11 @@ class _OutboundPageState extends State { _currentPage = 1; }); _updateRoute(page: 1, pageSize: value); + _fetchTransactions(page: 1); }, options: [ - for (final option in _pageSizeOptions) + for (final option + in OutboundTableSpec.pageSizeOptions) ShadOption( value: option, child: Text('$option개 / 페이지'), @@ -364,7 +616,7 @@ class _OutboundPageState extends State { Row( children: [ Text( - '${filtered.length}건 · 페이지 $currentPage / $totalPages', + '$totalItems건 · 페이지 $currentPage / $totalPages', style: theme.textTheme.small, ), const SizedBox(width: 12), @@ -404,6 +656,27 @@ class _OutboundPageState extends State { dateFormatter: _dateFormatter, currencyFormatter: _currencyFormatter, onEdit: () => _handleEdit(_selectedRecord!), + transitionsEnabled: _transitionsEnabled, + onSubmit: _canSubmit(_selectedRecord!) + ? () async => _submitRecord(_selectedRecord!) + : null, + onComplete: _canComplete(_selectedRecord!) + ? () async => _completeRecord(_selectedRecord!) + : null, + onApprove: _canApprove(_selectedRecord!) + ? () async => _approveRecord(_selectedRecord!) + : null, + onReject: _canReject(_selectedRecord!) + ? () async => _rejectRecord(_selectedRecord!) + : null, + onCancel: _canCancel(_selectedRecord!) + ? () async => _cancelRecord(_selectedRecord!) + : null, + canSubmit: !_isProcessing(_selectedRecord!.id) && !_isLoading, + canComplete: !_isProcessing(_selectedRecord!.id) && !_isLoading, + canApprove: !_isProcessing(_selectedRecord!.id) && !_isLoading, + canReject: !_isProcessing(_selectedRecord!.id) && !_isLoading, + canCancel: !_isProcessing(_selectedRecord!.id) && !_isLoading, ), ], ], @@ -421,7 +694,9 @@ class _OutboundPageState extends State { record.transactionNumber.toLowerCase().contains(query) || record.writer.toLowerCase().contains(query) || record.customers.any( - (customer) => customer.toLowerCase().contains(query), + (customer) => + customer.name.toLowerCase().contains(query) || + customer.code.toLowerCase().contains(query), ) || record.items.any( (item) => item.product.toLowerCase().contains(query), @@ -430,13 +705,27 @@ class _OutboundPageState extends State { range == null || (!record.processedAt.isBefore(range.start) && !record.processedAt.isAfter(range.end)); - final matchesWarehouse = - _appliedWarehouse == null || record.warehouse == _appliedWarehouse; + final matchesWarehouse = () { + if (_appliedWarehouseId != null) { + return record.warehouseId == _appliedWarehouseId; + } + if (_appliedWarehouseLabel != null && + _appliedWarehouseLabel!.isNotEmpty) { + return record.warehouse == _appliedWarehouseLabel; + } + return true; + }(); final matchesStatus = _appliedStatus == null || record.status == _appliedStatus; - final matchesCustomer = - _appliedCustomer == null || - record.customers.contains(_appliedCustomer); + final matchesCustomer = () { + final option = _appliedCustomer; + if (option.id == null) { + return true; + } + return record.customers.any( + (customer) => customer.customerId == option.id, + ); + }(); return matchesQuery && matchesRange && matchesWarehouse && @@ -463,17 +752,17 @@ class _OutboundPageState extends State { } List _buildRecordRow(OutboundRecord record) { - final primaryItem = record.items.first; + final primaryItem = record.items.isNotEmpty ? record.items.first : null; return [ record.number.split('-').last, _dateFormatter.format(record.processedAt), record.warehouse, record.transactionNumber, - primaryItem.product, - primaryItem.manufacturer, - primaryItem.unit, + primaryItem?.product ?? '-', + primaryItem?.manufacturer ?? '-', + primaryItem?.unit ?? '-', record.totalQuantity.toString(), - _currencyFormatter.format(primaryItem.price), + _currencyFormatter.format(primaryItem?.price ?? 0), record.status, record.writer, record.customerCount.toString(), @@ -487,7 +776,6 @@ class _OutboundPageState extends State { final record = await _showOutboundFormDialog(); if (record != null) { setState(() { - _records.insert(0, record); _selectedRecord = record; }); } @@ -497,22 +785,209 @@ class _OutboundPageState extends State { final updated = await _showOutboundFormDialog(initial: record); if (updated != null) { setState(() { - final index = _records.indexWhere( - (element) => element.number == record.number, - ); - if (index != -1) { - _records[index] = updated; - _selectedRecord = updated; - } + _selectedRecord = updated; }); } } + bool _isProcessing(int? id) { + if (id == null) { + return false; + } + return _processingTransactionIds.contains(id); + } + + bool _canSubmit(OutboundRecord record) { + if (!_transitionsEnabled) { + return false; + } + final status = record.status.trim(); + return status.contains('작성'); + } + + bool _canComplete(OutboundRecord record) { + if (!_transitionsEnabled) { + return false; + } + final status = record.status.trim(); + return status.contains('대기'); + } + + bool _canApprove(OutboundRecord record) { + if (!_transitionsEnabled) { + return false; + } + final normalized = record.status.replaceAll(RegExp(r'\s+'), ''); + return normalized.contains('출고대기'); + } + + bool _canReject(OutboundRecord record) { + if (!_transitionsEnabled) { + return false; + } + final normalized = record.status.replaceAll(RegExp(r'\s+'), ''); + return normalized.contains('출고대기'); + } + + bool _canCancel(OutboundRecord record) { + if (!_transitionsEnabled) { + return false; + } + final normalized = record.status.replaceAll(RegExp(r'\s+'), ''); + return normalized.contains('작성중') || normalized.contains('출고대기'); + } + + /// 실패 객체에서 사용자 노출용 메시지를 생성한다. + String _failureMessage(Object error, String fallbackMessage) { + final failure = Failure.from(error); + final description = failure.describe(); + if (description.isEmpty) { + return fallbackMessage; + } + return description; + } + + Future _submitRecord(OutboundRecord record) async { + final controller = _controller; + final id = record.id; + if (controller == null || id == null) { + if (mounted) { + SuperportToast.error(context, '출고 상신 대상을 찾을 수 없습니다.'); + } + return; + } + + try { + await controller.submitTransaction(id); + if (!mounted) { + return; + } + SuperportToast.success(context, '출고 상신이 완료되었습니다.'); + } catch (error) { + if (!mounted) { + return; + } + SuperportToast.error( + context, + _failureMessage(error, '출고 상신 처리에 실패했습니다. 다시 시도하세요.'), + ); + } + } + + Future _completeRecord(OutboundRecord record) async { + final controller = _controller; + final id = record.id; + if (controller == null || id == null) { + if (mounted) { + SuperportToast.error(context, '완료 처리할 출고 데이터를 찾지 못했습니다.'); + } + return; + } + + try { + await controller.completeTransaction(id); + if (!mounted) { + return; + } + SuperportToast.success(context, '출고 완료 처리되었습니다.'); + } catch (error) { + if (!mounted) { + return; + } + SuperportToast.error( + context, + _failureMessage(error, '출고 완료 처리에 실패했습니다. 잠시 후 다시 시도하세요.'), + ); + } + } + + Future _approveRecord(OutboundRecord record) async { + final controller = _controller; + final id = record.id; + if (controller == null || id == null) { + if (mounted) { + SuperportToast.error(context, '승인할 출고 데이터를 찾지 못했습니다.'); + } + return; + } + + try { + await controller.approveTransaction(id); + if (!mounted) { + return; + } + SuperportToast.success(context, '출고가 승인되었습니다.'); + } catch (error) { + if (!mounted) { + return; + } + SuperportToast.error( + context, + _failureMessage(error, '출고 승인 처리에 실패했습니다. 다시 시도하세요.'), + ); + } + } + + Future _rejectRecord(OutboundRecord record) async { + final controller = _controller; + final id = record.id; + if (controller == null || id == null) { + if (mounted) { + SuperportToast.error(context, '반려할 출고 데이터를 찾지 못했습니다.'); + } + return; + } + + try { + await controller.rejectTransaction(id); + if (!mounted) { + return; + } + SuperportToast.success(context, '출고가 반려되었습니다.'); + } catch (error) { + if (!mounted) { + return; + } + SuperportToast.error( + context, + _failureMessage(error, '출고 반려 처리에 실패했습니다. 잠시 후 다시 시도하세요.'), + ); + } + } + + Future _cancelRecord(OutboundRecord record) async { + final controller = _controller; + final id = record.id; + if (controller == null || id == null) { + if (mounted) { + SuperportToast.error(context, '취소할 출고 데이터를 찾지 못했습니다.'); + } + return; + } + + try { + await controller.cancelTransaction(id); + if (!mounted) { + return; + } + SuperportToast.success(context, '출고가 취소되었습니다.'); + } catch (error) { + if (!mounted) { + return; + } + SuperportToast.error( + context, + _failureMessage(error, '출고 취소 처리에 실패했습니다. 잠시 후 다시 시도하세요.'), + ); + } + } + void _applyFilters() { setState(() { _query = _pendingQuery.trim(); _appliedDateRange = _pendingDateRange; - _appliedWarehouse = _pendingWarehouse; + _appliedWarehouseId = _pendingWarehouseId; + _appliedWarehouseLabel = _pendingWarehouseLabel; _appliedStatus = _pendingStatus; _appliedCustomer = _pendingCustomer; _appliedIncludes = {..._pendingIncludes}; @@ -520,6 +995,7 @@ class _OutboundPageState extends State { _refreshSelection(); }); _updateRoute(page: 1); + _fetchTransactions(page: 1); } void _resetFilters() { @@ -529,38 +1005,46 @@ class _OutboundPageState extends State { _query = ''; _pendingDateRange = null; _appliedDateRange = null; - _pendingWarehouse = null; - _appliedWarehouse = null; + _pendingWarehouseId = null; + _appliedWarehouseId = null; + _pendingWarehouseLabel = null; + _appliedWarehouseLabel = null; _pendingStatus = null; _appliedStatus = null; - _pendingCustomer = null; - _appliedCustomer = null; + _pendingCustomer = CustomerFilterOption.all; + _appliedCustomer = CustomerFilterOption.all; _sortField = _OutboundSortField.processedAt; _sortAscending = false; - _pageSize = _pageSizeOptions.first; + _pageSize = OutboundTableSpec.pageSizeOptions.first; _currentPage = 1; - _pendingIncludes = {..._includeOptions}; - _appliedIncludes = {..._includeOptions}; + _pendingIncludes = {...OutboundTableSpec.defaultIncludeOptions}; + _appliedIncludes = {...OutboundTableSpec.defaultIncludeOptions}; _includeController ..value.clear() ..value.addAll(_pendingIncludes); _refreshSelection(); }); - _updateRoute(page: 1, pageSize: _pageSizeOptions.first); + _updateRoute(page: 1, pageSize: OutboundTableSpec.pageSizeOptions.first); + _fetchTransactions(page: 1); } bool get _hasAppliedFilters => _query.isNotEmpty || _appliedDateRange != null || - _appliedWarehouse != null || + _appliedWarehouseId != null || + (_appliedWarehouseLabel != null && _appliedWarehouseLabel!.isNotEmpty) || _appliedStatus != null || - _appliedCustomer != null || - !_setEquals(_appliedIncludes, _includeOptions.toSet()); + !_appliedCustomer.isAll || + !_setEquals( + _appliedIncludes, + OutboundTableSpec.defaultIncludeOptions.toSet(), + ); bool get _hasDirtyFilters => _pendingQuery.trim() != _query || !_isSameRange(_pendingDateRange, _appliedDateRange) || - _pendingWarehouse != _appliedWarehouse || + _pendingWarehouseId != _appliedWarehouseId || + _pendingWarehouseLabel != _appliedWarehouseLabel || _pendingStatus != _appliedStatus || _pendingCustomer != _appliedCustomer || !_setEquals(_pendingIncludes, _appliedIncludes); @@ -575,12 +1059,29 @@ class _OutboundPageState extends State { return a.start == b.start && a.end == b.end; } + CustomerFilterOption _resolveCustomerOption( + CustomerFilterOption target, + List options, + ) { + for (final option in options) { + if (option == target) { + return option; + } + if (target.id != null && option.id == target.id) { + return option; + } + } + return options.first; + } + void _applyRouteParameters(Uri uri, {bool initialize = false}) { final params = uri.queryParameters; final query = params['q'] ?? ''; + final warehouseIdParam = int.tryParse(params['warehouse_id'] ?? ''); final warehouseParam = params['warehouse']; final statusParam = params['status']; - final customerParam = params['customer']; + final customerLabelParam = params['customer']; + final customerIdParam = int.tryParse(params['customer_id'] ?? ''); final dateRange = _parseDateRange(params['start_date'], params['end_date']); final includesParam = params['include']; final resolvedSortField = @@ -590,39 +1091,66 @@ class _OutboundPageState extends State { final pageSizeParam = int.tryParse(params['page_size'] ?? ''); final pageParam = int.tryParse(params['page'] ?? ''); - final warehouse = _warehouseOptions.contains(warehouseParam) + final warehouseId = warehouseIdParam; + final warehouseLabel = warehouseParam != null && warehouseParam.isNotEmpty ? warehouseParam : null; final status = _statusOptions.contains(statusParam) ? statusParam : null; - final customer = _customerOptions.contains(customerParam) - ? customerParam - : null; + final routeCustomer = customerIdParam != null + ? CustomerFilterOption( + id: customerIdParam, + name: + (customerLabelParam != null && + customerLabelParam.trim().isNotEmpty) + ? customerLabelParam + : '고객사 #$customerIdParam', + ) + : CustomerFilterOption.all; final includes = includesParam == null || includesParam.isEmpty - ? {..._includeOptions} + ? {...OutboundTableSpec.defaultIncludeOptions} : includesParam .split(',') .map((token) => token.trim()) - .where((token) => _includeOptions.contains(token)) + .where( + (token) => + OutboundTableSpec.defaultIncludeOptions.contains(token), + ) .toSet(); final pageSize = - (pageSizeParam != null && _pageSizeOptions.contains(pageSizeParam)) + (pageSizeParam != null && + OutboundTableSpec.pageSizeOptions.contains(pageSizeParam)) ? pageSizeParam - : _pageSizeOptions.first; + : OutboundTableSpec.pageSizeOptions.first; final page = (pageParam != null && pageParam > 0) ? pageParam : 1; void assign() { _pendingQuery = query; _query = query; _searchController.text = query; - _pendingWarehouse = warehouse; - _appliedWarehouse = warehouse; + _pendingWarehouseId = warehouseId; + _appliedWarehouseId = warehouseId; + _pendingWarehouseLabel = warehouseLabel; + _appliedWarehouseLabel = warehouseLabel; _pendingStatus = status; _appliedStatus = status; - _pendingCustomer = customer; - _appliedCustomer = customer; + if (routeCustomer.id != null && + !_customerOptions.any((option) => option.id == routeCustomer.id)) { + _customerOptions = [ + ..._customerOptions, + routeCustomer, + ]; + } + final resolvedCustomer = _resolveCustomerOption( + routeCustomer, + _customerOptions, + ); + _pendingCustomer = resolvedCustomer; + _appliedCustomer = resolvedCustomer; _pendingDateRange = dateRange; _appliedDateRange = dateRange; - _pendingIncludes = includes.isEmpty ? {..._includeOptions} : includes; + _pendingIncludes = includes.isEmpty + ? {...OutboundTableSpec.defaultIncludeOptions} + : includes; _appliedIncludes = {..._pendingIncludes}; _includeController ..value.clear() @@ -640,6 +1168,7 @@ class _OutboundPageState extends State { } setState(assign); + _fetchTransactions(page: page); } void _goToPage(int page) { @@ -651,15 +1180,35 @@ class _OutboundPageState extends State { _currentPage = target; }); _updateRoute(page: target); + _fetchTransactions(page: target); } void _refreshSelection() { final filtered = _filteredRecords; - if (_selectedRecord != null && !filtered.contains(_selectedRecord)) { - _selectedRecord = filtered.isEmpty ? null : filtered.first; - } else if (_selectedRecord == null && filtered.isNotEmpty) { - _selectedRecord = filtered.first; + if (filtered.isEmpty) { + _selectedRecord = null; + return; } + + final current = _selectedRecord; + if (current != null) { + OutboundRecord? matched; + if (current.id != null) { + matched = filtered.firstWhere( + (element) => element.id == current.id, + orElse: () => filtered.first, + ); + } else { + matched = filtered.firstWhere( + (element) => element.transactionNumber == current.transactionNumber, + orElse: () => filtered.first, + ); + } + _selectedRecord = matched; + return; + } + + _selectedRecord = filtered.first; } void _updateRoute({int? page, int? pageSize}) { @@ -671,21 +1220,32 @@ class _OutboundPageState extends State { if (_query.isNotEmpty) { params['q'] = _query; } - if (_appliedWarehouse != null && _appliedWarehouse!.isNotEmpty) { - params['warehouse'] = _appliedWarehouse!; + if (_appliedWarehouseId != null) { + params['warehouse_id'] = _appliedWarehouseId.toString(); + if (_appliedWarehouseLabel != null && + _appliedWarehouseLabel!.isNotEmpty) { + params['warehouse'] = _appliedWarehouseLabel!; + } + } else if (_appliedWarehouseLabel != null && + _appliedWarehouseLabel!.isNotEmpty) { + params['warehouse'] = _appliedWarehouseLabel!; } if (_appliedStatus != null && _appliedStatus!.isNotEmpty) { params['status'] = _appliedStatus!; } - if (_appliedCustomer != null && _appliedCustomer!.isNotEmpty) { - params['customer'] = _appliedCustomer!; + if (!_appliedCustomer.isAll && _appliedCustomer.id != null) { + params['customer_id'] = _appliedCustomer.id!.toString(); + params['customer'] = _appliedCustomer.label; } final dateRange = _appliedDateRange; if (dateRange != null) { params['start_date'] = _formatDateParam(dateRange.start); params['end_date'] = _formatDateParam(dateRange.end); } - if (!_setEquals(_appliedIncludes, _includeOptions.toSet())) { + if (!_setEquals( + _appliedIncludes, + OutboundTableSpec.defaultIncludeOptions.toSet(), + )) { params['include'] = _appliedIncludes.join(','); } final sortParam = _encodeSortField(_sortField); @@ -698,7 +1258,7 @@ class _OutboundPageState extends State { if (targetPage > 1) { params['page'] = targetPage.toString(); } - if (targetPageSize != _pageSizeOptions.first) { + if (targetPageSize != OutboundTableSpec.pageSizeOptions.first) { params['page_size'] = targetPageSize.toString(); } @@ -747,17 +1307,6 @@ class _OutboundPageState extends State { return true; } - String _includeLabel(String value) { - switch (value) { - case 'lines': - return '라인 포함'; - case 'customers': - return '고객 포함'; - default: - return value; - } - } - _OutboundSortField? _sortFieldFromParam(String? value) { switch (value) { case 'warehouse': @@ -814,24 +1363,77 @@ class _OutboundPageState extends State { final processedAt = ValueNotifier( initial?.processedAt ?? DateTime.now(), ); - final warehouseController = TextEditingController( - text: initial?.warehouse ?? _warehouseOptions.first, - ); + final initialWarehouse = initial?.raw?.warehouse; + final int? initialWarehouseId = + initialWarehouse?.id ?? initial?.warehouseId; + final String? initialWarehouseName = + initialWarehouse?.name ?? initial?.warehouse; + final String? initialWarehouseCode = initialWarehouse?.code; + InventoryWarehouseOption? warehouseSelection; + if (initialWarehouseId != null && initialWarehouseName != null) { + warehouseSelection = InventoryWarehouseOption( + id: initialWarehouseId, + code: initialWarehouseCode ?? initialWarehouseName, + name: initialWarehouseName, + ); + } final statusValue = ValueNotifier( initial?.status ?? _statusOptions.first, ); + InventoryEmployeeSuggestion? writerSelection; + final initialWriter = initial?.raw?.createdBy; + if (initialWriter != null) { + writerSelection = InventoryEmployeeSuggestion( + id: initialWriter.id, + employeeNo: initialWriter.employeeNo, + name: initialWriter.name, + ); + } else if (initial?.writerId != null && + (initial?.writer ?? '').isNotEmpty) { + writerSelection = InventoryEmployeeSuggestion( + id: initial!.writerId!, + employeeNo: initial.writer, + name: initial.writer, + ); + } + String writerLabel(InventoryEmployeeSuggestion? suggestion) { + if (suggestion == null) { + return ''; + } + return '${suggestion.name} (${suggestion.employeeNo})'; + } + final writerController = TextEditingController( - text: initial?.writer ?? '이영희', + text: writerLabel(writerSelection), ); final remarkController = TextEditingController(text: initial?.remark ?? ''); - final customerController = ShadSelectController( - initialValue: (initial?.customers ?? const []).toSet(), - ); - if (customerController.value.isEmpty && _customerOptions.isNotEmpty) { - customerController.value.add(_customerOptions.first); - } + final initialCustomers = initial?.customers ?? const []; + final Set initialCustomerIds = { + for (final customer in initialCustomers) customer.customerId, + }; + final Map customerDraftMap = { + for (final customer in initialCustomers) + customer.customerId: _OutboundSelectedCustomerDraft( + option: InventoryCustomerOption( + id: customer.customerId, + code: customer.code, + name: customer.name, + industry: '-', + region: '-', + ), + linkId: customer.id, + note: customer.note, + ), + }; + List<_OutboundSelectedCustomerDraft> customerSelection = customerDraftMap + .values + .toList(growable: false); + final transactionTypeValue = + initial?.transactionType ?? + _transactionTypeLookup?.name ?? + _outboundTransactionTypeId; final transactionTypeController = TextEditingController( - text: initial?.transactionType ?? _outboundTransactionTypeId, + text: transactionTypeValue, ); final drafts = @@ -850,19 +1452,36 @@ class _OutboundPageState extends State { String? warehouseError; String? statusError; String? headerNotice; - String customerSearchQuery = ''; StateSetter? refreshForm; + StateSetter? refreshCancelAction; + StateSetter? refreshSaveAction; + var isSaving = false; OutboundRecord? result; final navigator = Navigator.of(context); - void handleSubmit() { + void updateSaving(bool next) { + isSaving = next; + refreshForm?.call(() {}); + refreshCancelAction?.call(() {}); + refreshSaveAction?.call(() {}); + } + + Future handleSubmit() async { + if (isSaving) { + return; + } + final validation = _validateOutboundForm( writerController: writerController, - warehouseValue: warehouseController.text, + writerSelection: writerSelection, + requireWriterSelection: initial == null, + warehouseSelection: warehouseSelection, statusValue: statusValue.value, - customerController: customerController, + selectedCustomers: customerSelection + .map((draft) => draft.option) + .toList(growable: false), drafts: drafts, lineErrors: lineErrors, ); @@ -879,34 +1498,191 @@ class _OutboundPageState extends State { return; } - final items = drafts + final controller = _controller; + if (controller == null) { + SuperportToast.error(context, '출고 컨트롤러를 찾을 수 없습니다.'); + return; + } + + final transactionTypeLookup = + _transactionTypeLookup ?? controller.transactionType; + if (transactionTypeLookup == null) { + SuperportToast.error(context, '출고 트랜잭션 유형 정보를 불러오지 못했습니다.'); + return; + } + + final statusItem = _statusLookup[statusValue.value]; + if (statusItem == null) { + SuperportToast.error(context, '선택한 상태 정보를 확인할 수 없습니다.'); + return; + } + + final warehouseId = warehouseSelection?.id ?? initialWarehouseId; + if (warehouseId == null) { + SuperportToast.error(context, '창고 정보를 확인할 수 없습니다.'); + return; + } + + final createdById = writerSelection?.id ?? initial?.writerId; + if (initial == null && createdById == null) { + SuperportToast.error(context, '작성자 정보를 확인할 수 없습니다.'); + return; + } + + final remarkText = remarkController.text.trim(); + final remarkValue = remarkText.isEmpty ? null : remarkText; + final transactionId = initial?.id; + + final lineDrafts = []; + for (var index = 0; index < drafts.length; index++) { + final draft = drafts[index]; + final productId = draft.productId ?? draft.suggestion?.id; + if (productId == null) { + continue; + } + final quantity = int.tryParse(draft.quantity.text.trim()) ?? 0; + final unitPrice = _parseCurrency(draft.price.text); + final noteText = draft.remark.text.trim(); + lineDrafts.add( + TransactionLineDraft( + id: draft.lineId, + lineNo: index + 1, + productId: productId, + quantity: quantity, + unitPrice: unitPrice, + note: noteText.isEmpty ? null : noteText, + ), + ); + } + + if (lineDrafts.isEmpty) { + SuperportToast.error(context, '최소 1개 이상의 품목을 선택하세요.'); + return; + } + + final customerDrafts = customerSelection .map( - (draft) => OutboundLineItem( - product: draft.product.text.trim(), - manufacturer: draft.manufacturer.text.trim(), - unit: draft.unit.text.trim(), - quantity: int.tryParse(draft.quantity.text.trim()) ?? 0, - price: _parseCurrency(draft.price.text), - remark: draft.remark.text.trim(), + (draft) => TransactionCustomerDraft( + id: draft.linkId, + customerId: draft.option.id, + note: draft.note, ), ) - .toList(); - items.sort((a, b) => a.product.compareTo(b.product)); - result = OutboundRecord( - number: initial?.number ?? _generateOutboundNumber(processedAt.value), - transactionNumber: - initial?.transactionNumber ?? - _generateTransactionNumber(processedAt.value), - transactionType: _outboundTransactionTypeId, - processedAt: processedAt.value, - warehouse: warehouseController.text, - status: statusValue.value, - writer: writerController.text.trim(), - remark: remarkController.text.trim(), - customers: customerController.value.toList(), - items: items, - ); - navigator.pop(); + .toList(growable: false); + + if (customerDrafts.isEmpty) { + SuperportToast.error(context, '최소 1개의 고객사를 선택하세요.'); + return; + } + + try { + updateSaving(true); + + if (initial != null) { + final OutboundRecord initialRecord = initial; + if (transactionId == null) { + updateSaving(false); + SuperportToast.error(context, '수정 대상 ID를 확인할 수 없습니다.'); + return; + } + final updated = await controller.updateTransaction( + transactionId, + StockTransactionUpdateInput( + transactionStatusId: statusItem.id, + note: remarkValue, + ), + refreshAfter: false, + ); + result = updated; + final StockTransaction? currentTransaction = initialRecord.raw; + final currentLines = + currentTransaction?.lines ?? const []; + final currentCustomers = + currentTransaction?.customers ?? + const []; + final linePlan = _detailSyncService.buildLinePlan( + drafts: lineDrafts, + currentLines: currentLines, + ); + final customerPlan = _detailSyncService.buildCustomerPlan( + drafts: customerDrafts, + currentCustomers: currentCustomers, + ); + if (linePlan.hasChanges) { + await controller.syncTransactionLines(transactionId, linePlan); + } + if (customerPlan.hasChanges) { + await controller.syncTransactionCustomers( + transactionId, + customerPlan, + ); + } + await controller.refresh(); + updateSaving(false); + if (!mounted) { + return; + } + SuperportToast.success(context, '출고 정보가 수정되었습니다.'); + navigator.pop(); + return; + } + + if (createdById == null) { + updateSaving(false); + SuperportToast.error(context, '작성자 선택이 필요합니다.'); + return; + } + + final createLines = lineDrafts + .map( + (draft) => TransactionLineCreateInput( + lineNo: draft.lineNo, + productId: draft.productId, + quantity: draft.quantity, + unitPrice: draft.unitPrice, + note: draft.note, + ), + ) + .toList(growable: false); + + final createCustomers = customerDrafts + .map( + (draft) => TransactionCustomerCreateInput( + customerId: draft.customerId, + note: draft.note, + ), + ) + .toList(growable: false); + + final created = await controller.createTransaction( + StockTransactionCreateInput( + transactionTypeId: transactionTypeLookup.id, + transactionStatusId: statusItem.id, + warehouseId: warehouseId, + transactionDate: processedAt.value, + createdById: createdById, + note: remarkValue, + lines: createLines, + customers: createCustomers, + ), + ); + result = created; + updateSaving(false); + if (!mounted) { + return; + } + SuperportToast.success(context, '출고가 등록되었습니다.'); + navigator.pop(); + } catch (error) { + updateSaving(false); + if (!mounted) { + return; + } + SuperportToast.error( + context, + _failureMessage(error, '저장 중 오류가 발생했습니다. 잠시 후 다시 시도하세요.'), + ); + } } await showSuperportDialog( @@ -916,11 +1692,30 @@ class _OutboundPageState extends State { constraints: const BoxConstraints(maxWidth: 880, maxHeight: 720), onSubmit: handleSubmit, actions: [ - ShadButton.ghost( - onPressed: () => navigator.pop(), - child: const Text('취소'), + StatefulBuilder( + builder: (context, setState) { + refreshCancelAction = setState; + return ShadButton.ghost( + onPressed: isSaving ? null : () => navigator.pop(), + child: const Text('취소'), + ); + }, + ), + StatefulBuilder( + builder: (context, setState) { + refreshSaveAction = setState; + return ShadButton( + onPressed: isSaving ? null : () => handleSubmit(), + child: isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('저장'), + ); + }, ), - ShadButton(onPressed: handleSubmit, child: const Text('저장')), ], body: StatefulBuilder( builder: (context, setState) { @@ -944,8 +1739,9 @@ class _OutboundPageState extends State { child: SuperportDatePickerButton( value: processedAt.value, dateFormat: _dateFormatter, - firstDate: DateTime(2020), - lastDate: DateTime(2030), + firstDate: OutboundTableSpec.dateRangeFirstDate, + lastDate: OutboundTableSpec.dateRangeLastDate, + enabled: initial == null, onChanged: (date) { processedAt.value = date; setState(() {}); @@ -959,24 +1755,17 @@ class _OutboundPageState extends State { label: '창고', required: true, errorText: warehouseError, - child: ShadSelect( - initialValue: warehouseController.text, - selectedOptionBuilder: (context, value) => - Text(value), - onChanged: (value) { - if (value != null) { - warehouseController.text = value; - setState(() { - if (warehouseError != null) { - warehouseError = null; - } - }); - } + child: InventoryWarehouseSelectField( + initialWarehouseId: initialWarehouseId, + enabled: initial == null, + onChanged: (option) { + warehouseSelection = option; + setState(() { + if (warehouseError != null) { + warehouseError = null; + } + }); }, - options: [ - for (final option in _warehouseOptions) - ShadOption(value: option, child: Text(option)), - ], ), ), ), @@ -1025,15 +1814,35 @@ class _OutboundPageState extends State { label: '작성자', required: true, errorText: writerError, - child: ShadInput( + child: InventoryEmployeeAutocompleteField( controller: writerController, - onChanged: (_) { + initialSuggestion: writerSelection, + enabled: initial == null, + onSuggestionSelected: (suggestion) { + writerSelection = suggestion; if (writerError != null) { setState(() { writerError = null; }); } }, + onChanged: () { + if (initial == null) { + final currentText = writerController.text.trim(); + final selectedLabel = writerLabel( + writerSelection, + ); + if (currentText.isEmpty || + currentText != selectedLabel) { + writerSelection = null; + } + if (writerError != null) { + setState(() { + writerError = null; + }); + } + } + }, ), ), ), @@ -1043,96 +1852,36 @@ class _OutboundPageState extends State { label: '출고 고객사', required: true, errorText: customerError, - child: Builder( - builder: (context) { - final filteredCustomers = - InventoryCustomerCatalog.filter( - customerSearchQuery, - ); - final hasResults = filteredCustomers.isNotEmpty; - return ShadSelect.multipleWithSearch( - controller: customerController, - placeholder: const Text('고객사 선택'), - searchPlaceholder: const Text('고객사 이름 또는 코드 검색'), - searchInputLeading: const Icon( - lucide.LucideIcons.search, - size: 16, - ), - clearSearchOnClose: true, - closeOnSelect: false, - options: [ - for (final customer in filteredCustomers) - ShadOption( - value: customer.name, - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - customer.name, - style: theme.textTheme.p, - ), - const SizedBox(height: 4), - Text( - '${customer.code} · ${customer.industry} · ${customer.region}', - style: theme.textTheme.muted.copyWith( - fontSize: 12, - ), - ), - ], - ), - ), - ], - footer: hasResults - ? null - : Padding( - padding: const EdgeInsets.all(12), - child: Text( - '검색 결과가 없습니다.', - style: theme.textTheme.muted, - ), - ), - onSearchChanged: (query) { - setState(() { - customerSearchQuery = query; - }); - }, - selectedOptionsBuilder: (context, values) { - if (values.isEmpty) { - return const Text('선택된 고객사가 없습니다'); - } - return Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final value in values) - ShadBadge( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - child: Text( - InventoryCustomerCatalog.displayLabel( - value, - ), - ), - ), - ), - ], - ); - }, - onChanged: (values) { - if (customerError != null && - values.isNotEmpty) { - setState(() { - customerError = null; - }); - } else { - setState(() {}); - } - }, + child: InventoryCustomerMultiSelectField( + initialCustomerIds: initialCustomerIds, + enabled: initial?.status != '출고완료', + onChanged: (options) { + final nextMap = + {}; + for (final option in options) { + final existing = customerDraftMap[option.id]; + if (existing != null) { + nextMap[option.id] = existing; + } else { + nextMap[option.id] = + _OutboundSelectedCustomerDraft( + option: option, + ); + } + } + customerDraftMap + ..clear() + ..addAll(nextMap); + customerSelection = customerDraftMap.values.toList( + growable: false, ); + if (customerError != null && options.isNotEmpty) { + setState(() { + customerError = null; + }); + } else { + setState(() {}); + } }, ), ), @@ -1182,7 +1931,7 @@ class _OutboundPageState extends State { _OutboundSummaryBadge( icon: lucide.LucideIcons.users, label: '고객사 수', - value: '${customerController.value.length}곳', + value: '${customerSelection.length}곳', ), ], ), @@ -1259,44 +2008,14 @@ class _OutboundPageState extends State { for (final draft in drafts) { draft.dispose(); } - warehouseController.dispose(); statusValue.dispose(); writerController.dispose(); remarkController.dispose(); transactionTypeController.dispose(); processedAt.dispose(); - customerController.dispose(); return result; } - - static String _generateOutboundNumber(DateTime date) { - final stamp = DateFormat('yyyyMMdd-HHmmss').format(date); - return 'OUT-$stamp'; - } - - static String _generateTransactionNumber(DateTime date) { - final stamp = DateFormat('yyyyMMdd-HHmmss').format(date); - return 'TX-$stamp'; - } - - static const _tableHeaders = [ - '번호', - '처리일자', - '창고', - '트랜잭션번호', - '제품', - '제조사', - '단위', - '수량', - '단가', - '상태', - '작성자', - '고객수', - '품목수', - '총수량', - '비고', - ]; } class _OutboundDetailCard extends StatelessWidget { @@ -1305,12 +2024,34 @@ class _OutboundDetailCard extends StatelessWidget { required this.dateFormatter, required this.currencyFormatter, required this.onEdit, + this.transitionsEnabled = true, + this.onSubmit, + this.onComplete, + this.onApprove, + this.onReject, + this.onCancel, + this.canSubmit = true, + this.canComplete = true, + this.canApprove = true, + this.canReject = true, + this.canCancel = true, }); final OutboundRecord record; final DateFormat dateFormatter; final NumberFormat currencyFormatter; final VoidCallback onEdit; + final bool transitionsEnabled; + final Future Function()? onSubmit; + final Future Function()? onComplete; + final Future Function()? onApprove; + final Future Function()? onReject; + final Future Function()? onCancel; + final bool canSubmit; + final bool canComplete; + final bool canApprove; + final bool canReject; + final bool canCancel; @override Widget build(BuildContext context) { @@ -1321,10 +2062,60 @@ class _OutboundDetailCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('선택된 출고 상세', style: theme.textTheme.h3), - ShadButton.outline( - leading: const Icon(lucide.LucideIcons.pencil, size: 16), - onPressed: onEdit, - child: const Text('수정'), + Wrap( + spacing: 8, + children: [ + if (onSubmit != null) + ShadButton.outline( + onPressed: canSubmit + ? () { + onSubmit?.call(); + } + : null, + child: const Text('상신'), + ), + if (onApprove != null) + ShadButton.outline( + onPressed: canApprove + ? () { + onApprove?.call(); + } + : null, + child: const Text('승인'), + ), + if (onReject != null) + ShadButton.outline( + onPressed: canReject + ? () { + onReject?.call(); + } + : null, + child: const Text('반려'), + ), + if (onCancel != null) + ShadButton.outline( + onPressed: canCancel + ? () { + onCancel?.call(); + } + : null, + child: const Text('취소'), + ), + if (onComplete != null) + ShadButton.outline( + onPressed: canComplete + ? () { + onComplete?.call(); + } + : null, + child: const Text('출고 완료'), + ), + ShadButton.outline( + leading: const Icon(lucide.LucideIcons.pencil, size: 16), + onPressed: onEdit, + child: const Text('수정'), + ), + ], ), ], ), @@ -1335,6 +2126,15 @@ class _OutboundDetailCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (!transitionsEnabled) ...[ + ShadBadge.outline( + child: Text( + '재고 상태 전이가 비활성화된 상태입니다.', + style: theme.textTheme.small, + ), + ), + const SizedBox(height: 16), + ], Wrap( spacing: 12, runSpacing: 12, @@ -1370,7 +2170,7 @@ class _OutboundDetailCard extends StatelessWidget { horizontal: 10, vertical: 6, ), - child: Text(customer), + child: Text('${customer.name} · ${customer.code}'), ), ), ], @@ -1442,8 +2242,10 @@ class _OutboundLineItemRow extends StatelessWidget { productFocusNode: draft.productFocus, manufacturerController: draft.manufacturer, unitController: draft.unit, - onCatalogMatched: (catalog) { - draft.catalogMatch = catalog; + initialSuggestion: draft.suggestion, + onSuggestionSelected: (suggestion) { + draft.suggestion = suggestion; + draft.productId = suggestion?.id; onFieldChanged(_OutboundLineField.product); }, onChanged: () => onFieldChanged(_OutboundLineField.product), @@ -1538,6 +2340,8 @@ class _OutboundLineItemDraft { required this.quantity, required this.price, required this.remark, + this.lineId, + this.productId, }); final TextEditingController product; @@ -1547,7 +2351,9 @@ class _OutboundLineItemDraft { final TextEditingController quantity; final TextEditingController price; final TextEditingController remark; - InventoryProductCatalogItem? catalogMatch; + final int? lineId; + int? productId; + InventoryProductSuggestion? suggestion; factory _OutboundLineItemDraft.empty() { return _OutboundLineItemDraft._( @@ -1558,6 +2364,8 @@ class _OutboundLineItemDraft { quantity: TextEditingController(text: '1'), price: TextEditingController(text: '0'), remark: TextEditingController(), + lineId: null, + productId: null, ); } @@ -1570,8 +2378,16 @@ class _OutboundLineItemDraft { quantity: TextEditingController(text: '${item.quantity}'), price: TextEditingController(text: item.price.toStringAsFixed(0)), remark: TextEditingController(text: item.remark), + lineId: item.id, + productId: item.productId, + ); + draft.suggestion = InventoryProductSuggestion( + id: item.productId, + code: item.productCode, + name: item.product, + vendorName: item.manufacturer, + unitName: item.unit, ); - draft.catalogMatch = InventoryProductCatalog.match(item.product); return draft; } @@ -1622,6 +2438,18 @@ class _OutboundLineErrors { } } +class _OutboundSelectedCustomerDraft { + _OutboundSelectedCustomerDraft({ + required this.option, + this.linkId, + this.note, + }); + + final InventoryCustomerOption option; + final int? linkId; + final String? note; +} + double _parseCurrency(String input) { final normalized = input.replaceAll(RegExp(r'[^0-9.-]'), ''); return double.tryParse(normalized.isEmpty ? '0' : normalized) ?? 0; @@ -1630,9 +2458,11 @@ double _parseCurrency(String input) { /// 출고 폼의 필수 입력 및 품목 조건을 검증한다. _OutboundFormValidation _validateOutboundForm({ required TextEditingController writerController, - required String warehouseValue, + required InventoryEmployeeSuggestion? writerSelection, + required bool requireWriterSelection, + required InventoryWarehouseOption? warehouseSelection, required String statusValue, - required ShadSelectController customerController, + required List selectedCustomers, required List<_OutboundLineItemDraft> drafts, required Map<_OutboundLineItemDraft, _OutboundLineErrors> lineErrors, }) { @@ -1643,12 +2473,18 @@ _OutboundFormValidation _validateOutboundForm({ String? statusError; String? headerNotice; - if (writerController.text.trim().isEmpty) { + final writerText = writerController.text.trim(); + if (writerText.isEmpty) { writerError = '작성자를 입력하세요.'; isValid = false; } - if (warehouseValue.trim().isEmpty) { + if (requireWriterSelection && writerSelection == null) { + writerError = '작성자는 자동완성에서 선택하세요.'; + isValid = false; + } + + if (warehouseSelection == null) { warehouseError = '창고를 선택하세요.'; isValid = false; } @@ -1658,7 +2494,7 @@ _OutboundFormValidation _validateOutboundForm({ isValid = false; } - if (customerController.value.isEmpty) { + if (selectedCustomers.isEmpty) { customerError = '최소 1개의 고객사를 선택하세요.'; isValid = false; } @@ -1674,13 +2510,13 @@ _OutboundFormValidation _validateOutboundForm({ errors.product = '제품을 입력하세요.'; hasLineError = true; isValid = false; - } else if (draft.catalogMatch == null) { + } else if (draft.suggestion == null) { errors.product = '제품은 목록에서 선택하세요.'; hasLineError = true; isValid = false; } - final productKey = draft.catalogMatch?.code ?? productText.toLowerCase(); + final productKey = draft.suggestion?.code ?? productText.toLowerCase(); if (productKey.isNotEmpty && !seenProductKeys.add(productKey)) { errors.product = '동일 제품이 중복되었습니다.'; hasLineError = true; @@ -1804,6 +2640,60 @@ class _OutboundSummaryBadge extends StatelessWidget { } } +class CustomerFilterOption { + const CustomerFilterOption({this.id, required this.name, this.code}); + + final int? id; + final String name; + final String? code; + + static const CustomerFilterOption all = CustomerFilterOption( + id: null, + name: '전체 고객사', + ); + + factory CustomerFilterOption.fromCustomer(Customer customer) { + return CustomerFilterOption( + id: customer.id, + name: customer.customerName, + code: customer.customerCode, + ); + } + + String get label { + if (id == null) { + return name; + } + final trimmedName = name.trim(); + final trimmedCode = code?.trim(); + if (trimmedCode == null || trimmedCode.isEmpty) { + return trimmedName; + } + return '$trimmedName ($trimmedCode)'; + } + + String get cacheKey => id?.toString() ?? name; + + bool get isAll => id == null; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! CustomerFilterOption) { + return false; + } + if (id != null && other.id != null) { + return id == other.id; + } + return name == other.name; + } + + @override + int get hashCode => cacheKey.hashCode; +} + enum _OutboundSortField { processedAt, warehouse, @@ -1812,131 +2702,6 @@ enum _OutboundSortField { customerCount, } -class OutboundRecord { - OutboundRecord({ - required this.number, - required this.transactionNumber, - required this.transactionType, - required this.processedAt, - required this.warehouse, - required this.status, - required this.writer, - required this.customers, - required this.remark, - required this.items, - }); - - final String number; - final String transactionNumber; - final String transactionType; - final DateTime processedAt; - final String warehouse; - final String status; - final String writer; - final List customers; - final String remark; - final List items; - - int get customerCount => customers.length; - int get itemCount => items.length; - int get totalQuantity => - items.fold(0, (sum, item) => sum + item.quantity); - double get totalAmount => - items.fold(0, (sum, item) => sum + (item.price * item.quantity)); -} - -class OutboundLineItem { - OutboundLineItem({ - required this.product, - required this.manufacturer, - required this.unit, - required this.quantity, - required this.price, - required this.remark, - }); - - final String product; - final String manufacturer; - final String unit; - final int quantity; - final double price; - final String remark; -} - -final List _mockOutboundRecords = [ - OutboundRecord( - number: 'OUT-20240302-001', - transactionNumber: 'TX-20240302-010', - transactionType: _outboundTransactionTypeId, - processedAt: DateTime(2024, 3, 2), - warehouse: '서울 1창고', - status: '출고대기', - writer: '이영희', - customers: ['슈퍼포트 파트너', '그린에너지'], - remark: '-', - items: [ - OutboundLineItem( - product: 'XR-5000', - manufacturer: '슈퍼벤더', - unit: 'EA', - quantity: 30, - price: 130000, - remark: '긴급 출고', - ), - OutboundLineItem( - product: 'XR-5001', - manufacturer: '슈퍼벤더', - unit: 'EA', - quantity: 20, - price: 118000, - remark: '', - ), - ], - ), - OutboundRecord( - number: 'OUT-20240304-004', - transactionNumber: 'TX-20240304-005', - transactionType: _outboundTransactionTypeId, - processedAt: DateTime(2024, 3, 4), - warehouse: '부산 센터', - status: '출고완료', - writer: '강물류', - customers: ['테크솔루션'], - remark: '완납', - items: [ - OutboundLineItem( - product: 'Eco-200', - manufacturer: '그린텍', - unit: 'EA', - quantity: 15, - price: 150000, - remark: '', - ), - ], - ), - OutboundRecord( - number: 'OUT-20240309-006', - transactionNumber: 'TX-20240309-012', - transactionType: _outboundTransactionTypeId, - processedAt: DateTime(2024, 3, 9), - warehouse: '대전 물류', - status: '작성중', - writer: '최준비', - customers: ['에이치솔루션', '블루하이드'], - remark: '운송배차 예정', - items: [ - OutboundLineItem( - product: 'Delta-One', - manufacturer: '델타', - unit: 'SET', - quantity: 6, - price: 460000, - remark: '시연용', - ), - ], - ), -]; - class _DetailChip extends StatelessWidget { const _DetailChip({required this.label, required this.value}); diff --git a/lib/features/inventory/rental/presentation/pages/rental_page.dart b/lib/features/inventory/rental/presentation/pages/rental_page.dart index 3dba065..6ec704e 100644 --- a/lib/features/inventory/rental/presentation/pages/rental_page.dart +++ b/lib/features/inventory/rental/presentation/pages/rental_page.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; @@ -11,9 +13,23 @@ import 'package:superport_v2/widgets/components/superport_dialog.dart'; import 'package:superport_v2/widgets/components/feedback.dart'; import 'package:superport_v2/widgets/components/form_field.dart'; import 'package:superport_v2/widgets/components/empty_state.dart'; -import 'package:superport_v2/features/inventory/shared/catalogs.dart'; import 'package:superport_v2/features/inventory/shared/widgets/product_autocomplete_field.dart'; +import 'package:superport_v2/features/inventory/shared/widgets/employee_autocomplete_field.dart'; +import 'package:superport_v2/features/inventory/shared/widgets/customer_multi_select_field.dart'; +import 'package:superport_v2/features/inventory/shared/widgets/warehouse_select_field.dart'; +import 'package:superport_v2/core/config/environment.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/inventory/rental/presentation/controllers/rental_controller.dart'; +import 'package:superport_v2/features/inventory/rental/presentation/models/rental_record.dart'; +import 'package:superport_v2/features/inventory/rental/presentation/specs/rental_table_spec.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/features/inventory/transactions/presentation/services/transaction_detail_sync_service.dart'; +import '../../../lookups/domain/entities/lookup_item.dart'; +import '../../../lookups/domain/repositories/inventory_lookup_repository.dart'; const String _rentalTransactionTypeRent = '대여'; const String _rentalTransactionTypeReturn = '반납'; @@ -37,11 +53,15 @@ class _RentalPageState extends State { symbol: '₩', decimalDigits: 0, ); + final TransactionDetailSyncService _detailSyncService = + const TransactionDetailSyncService(); String _query = ''; String _pendingQuery = ''; - String? _appliedWarehouse; - String? _pendingWarehouse; + int? _appliedWarehouseId; + int? _pendingWarehouseId; + String? _appliedWarehouseLabel; + String? _pendingWarehouseLabel; String? _appliedStatus; String? _pendingStatus; String? _appliedRentalType; @@ -50,33 +70,127 @@ class _RentalPageState extends State { DateTimeRange? _pendingDateRange; DateTimeRange? _appliedReturnRange; DateTimeRange? _pendingReturnRange; - final List _records = _mockRentalRecords; + List _records = []; RentalRecord? _selectedRecord; - static const _statusOptions = ['대여중', '반납대기', '완료']; - static const _warehouseOptions = ['서울 1창고', '부산 센터', '대전 물류']; - static const _rentalTypes = ['대여', '반납']; - static const _pageSizeOptions = [10, 20, 50]; - static const _includeOptions = ['lines', 'customers']; - static final List _customerOptions = InventoryCustomerCatalog.items - .map((item) => item.name) - .toList(); + PaginatedResult? _result; + bool _isLoading = false; + String? _errorMessage; + Set _processingTransactionIds = {}; + + late List _statusOptions; + final Map _statusLookup = {}; + LookupItem? _rentTransactionType; + LookupItem? _returnTransactionType; int _currentPage = 1; - int _pageSize = _pageSizeOptions.first; + int _pageSize = RentalTableSpec.pageSizeOptions.first; _RentalSortField _sortField = _RentalSortField.processedAt; bool _sortAscending = false; - Set _appliedIncludes = {..._includeOptions}; - Set _pendingIncludes = {..._includeOptions}; + RentalController? _controller; + Set _appliedIncludes = {...RentalTableSpec.defaultIncludeOptions}; + Set _pendingIncludes = {...RentalTableSpec.defaultIncludeOptions}; late final ShadSelectController _includeController; + bool get _transitionsEnabled => + Environment.flag('FEATURE_STOCK_TRANSITIONS_ENABLED', defaultValue: true); + @override void initState() { super.initState(); _includeController = ShadSelectController( initialValue: _pendingIncludes.toSet(), ); + _statusOptions = List.from(RentalTableSpec.fallbackStatusOptions); + _controller = _createController(); + _controller?.addListener(_handleControllerChanged); _applyRouteParameters(widget.routeUri, initialize: true); + _initializeController(); + } + + RentalController? _createController() { + final getIt = GetIt.I; + if (!getIt.isRegistered() || + !getIt.isRegistered() || + !getIt.isRegistered() || + !getIt.isRegistered()) { + return null; + } + return RentalController( + transactionRepository: getIt(), + lineRepository: getIt(), + customerRepository: getIt(), + lookupRepository: getIt(), + fallbackStatusOptions: RentalTableSpec.fallbackStatusOptions, + rentTransactionKeywords: RentalTableSpec.rentTransactionKeywords, + returnTransactionKeywords: RentalTableSpec.returnTransactionKeywords, + ); + } + + void _initializeController() { + final controller = _controller; + if (controller == null) { + return; + } + Future.microtask(() async { + await controller.loadStatusOptions(); + final hasTypes = await controller.resolveTransactionTypes(); + if (!mounted) { + return; + } + if (hasTypes) { + await _fetchTransactions(page: _currentPage); + } + }); + } + + void _handleControllerChanged() { + if (!mounted) { + return; + } + final controller = _controller; + if (controller == null) { + return; + } + final statusSource = controller.statusOptions.isEmpty + ? controller.fallbackStatusOptions + : controller.statusOptions; + final resolvedStatusOptions = List.from(statusSource); + final resolvedLookup = Map.from( + controller.statusLookup, + ); + + var pendingStatus = _pendingStatus; + if (pendingStatus != null && + !resolvedStatusOptions.contains(pendingStatus)) { + pendingStatus = null; + } + var appliedStatus = _appliedStatus; + if (appliedStatus != null && + !resolvedStatusOptions.contains(appliedStatus)) { + appliedStatus = null; + } + + setState(() { + _statusOptions = resolvedStatusOptions.isEmpty + ? List.from(RentalTableSpec.fallbackStatusOptions) + : resolvedStatusOptions; + _statusLookup + ..clear() + ..addAll(resolvedLookup); + _pendingStatus = pendingStatus; + _appliedStatus = appliedStatus; + _rentTransactionType = controller.rentTransactionType; + _returnTransactionType = controller.returnTransactionType; + _result = controller.result; + _records = controller.records.toList(); + _isLoading = controller.isLoading; + _errorMessage = controller.errorMessage; + _processingTransactionIds = Set.from( + controller.processingTransactionIds, + ); + _refreshSelection(); + }); } @override @@ -89,26 +203,71 @@ class _RentalPageState extends State { @override void dispose() { + _controller?.removeListener(_handleControllerChanged); + _controller?.dispose(); _searchController.dispose(); _includeController.dispose(); super.dispose(); } + Future _fetchTransactions({int? page}) async { + final controller = _controller; + if (controller == null) { + return; + } + + final targetPage = page ?? _currentPage; + final includes = _appliedIncludes.isEmpty + ? RentalTableSpec.defaultIncludeOptions.toList(growable: false) + : _appliedIncludes.toList(growable: false); + final statusItem = _appliedStatus == null + ? null + : _statusLookup[_appliedStatus!]; + + int? transactionTypeId; + switch (_appliedRentalType) { + case _rentalTransactionTypeRent: + transactionTypeId = controller.rentTransactionType?.id; + break; + case _rentalTransactionTypeReturn: + transactionTypeId = controller.returnTransactionType?.id; + break; + default: + transactionTypeId = null; + } + + setState(() { + _currentPage = targetPage; + }); + + final filter = StockTransactionListFilter( + page: targetPage, + pageSize: _pageSize, + query: _query.isEmpty ? null : _query, + transactionTypeId: transactionTypeId, + transactionStatusId: statusItem != null && statusItem.id > 0 + ? statusItem.id + : null, + from: _appliedDateRange?.start, + to: _appliedDateRange?.end, + include: includes, + ); + + await controller.fetchTransactions(filter: filter); + } + @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); final filtered = _filteredRecords; - final totalPages = _calculateTotalPages(filtered.length); + final totalItems = _result?.total ?? filtered.length; + final totalPages = _calculateTotalPages(totalItems); final currentPage = totalPages <= 1 ? 1 : (_currentPage < 1 ? 1 : (_currentPage > totalPages ? totalPages : _currentPage)); - final startIndex = (currentPage - 1) * _pageSize; - final visibleRecords = filtered - .skip(startIndex) - .take(_pageSize) - .toList(growable: false); + final visibleRecords = filtered; return AppLayout( title: '대여 관리', @@ -120,7 +279,7 @@ class _RentalPageState extends State { ], actions: [ PermissionGate( - resource: '/inventory/rental', + resource: PermissionResources.stockTransactions, action: PermissionAction.create, child: ShadButton( leading: const Icon(lucide.LucideIcons.plus, size: 16), @@ -129,7 +288,7 @@ class _RentalPageState extends State { ), ), PermissionGate( - resource: '/inventory/rental', + resource: PermissionResources.stockTransactions, action: PermissionAction.edit, child: ShadButton.outline( leading: const Icon(lucide.LucideIcons.pencil, size: 16), @@ -152,7 +311,7 @@ class _RentalPageState extends State { width: 260, child: ShadInput( controller: _searchController, - placeholder: const Text('트랜잭션번호, 작성자, 제품, 고객사 검색'), + placeholder: const Text(RentalTableSpec.searchPlaceholder), leading: const Icon(lucide.LucideIcons.search, size: 16), onChanged: (_) { setState(() { @@ -168,8 +327,8 @@ class _RentalPageState extends State { value: _pendingDateRange, dateFormat: _dateFormatter, onChanged: (range) => setState(() => _pendingDateRange = range), - firstDate: DateTime(2020), - lastDate: DateTime(2030), + firstDate: RentalTableSpec.dateRangeFirstDate, + lastDate: RentalTableSpec.dateRangeLastDate, ), ), SizedBox( @@ -178,23 +337,25 @@ class _RentalPageState extends State { value: _pendingReturnRange, dateFormat: _dateFormatter, onChanged: (range) => setState(() => _pendingReturnRange = range), - firstDate: DateTime(2020), - lastDate: DateTime(2030), + firstDate: RentalTableSpec.dateRangeFirstDate, + lastDate: RentalTableSpec.dateRangeLastDate, placeholder: '반납 예정일 범위', ), ), SizedBox( width: 200, - child: ShadSelect( - key: ValueKey(_pendingWarehouse ?? 'all'), - initialValue: _pendingWarehouse, - selectedOptionBuilder: (_, value) => Text(value ?? '전체 창고'), - onChanged: (value) => setState(() => _pendingWarehouse = value), - options: [ - const ShadOption(value: null, child: Text('전체 창고')), - for (final option in _warehouseOptions) - ShadOption(value: option, child: Text(option)), - ], + child: InventoryWarehouseSelectField( + key: ValueKey(_pendingWarehouseId ?? 'all'), + initialWarehouseId: _pendingWarehouseId, + includeAllOption: true, + allLabel: RentalTableSpec.allWarehouseLabel, + placeholder: const Text(RentalTableSpec.allWarehouseLabel), + onChanged: (option) { + setState(() { + _pendingWarehouseId = option?.id; + _pendingWarehouseLabel = option?.name; + }); + }, ), ), SizedBox( @@ -202,10 +363,14 @@ class _RentalPageState extends State { child: ShadSelect( key: ValueKey(_pendingStatus ?? 'all'), initialValue: _pendingStatus, - selectedOptionBuilder: (_, value) => Text(value ?? '전체 상태'), + selectedOptionBuilder: (_, value) => + Text(value ?? RentalTableSpec.allStatusLabel), onChanged: (value) => setState(() => _pendingStatus = value), options: [ - const ShadOption(value: null, child: Text('전체 상태')), + const ShadOption( + value: null, + child: Text(RentalTableSpec.allStatusLabel), + ), for (final option in _statusOptions) ShadOption(value: option, child: Text(option)), ], @@ -216,11 +381,15 @@ class _RentalPageState extends State { child: ShadSelect( key: ValueKey(_pendingRentalType ?? 'all'), initialValue: _pendingRentalType, - selectedOptionBuilder: (_, value) => Text(value ?? '대여구분 전체'), + selectedOptionBuilder: (_, value) => + Text(value ?? RentalTableSpec.allRentalTypeLabel), onChanged: (value) => setState(() => _pendingRentalType = value), options: [ - const ShadOption(value: null, child: Text('대여구분 전체')), - for (final option in _rentalTypes) + const ShadOption( + value: null, + child: Text(RentalTableSpec.allRentalTypeLabel), + ), + for (final option in RentalTableSpec.rentalTypes) ShadOption(value: option, child: Text(option)), ], ), @@ -255,7 +424,7 @@ class _RentalPageState extends State { initialValues: _pendingIncludes, selectedOptionsBuilder: (context, values) { if (values.isEmpty) { - return const Text('Include 없음'); + return const Text(RentalTableSpec.includeEmptyLabel); } return Wrap( spacing: 6, @@ -268,7 +437,7 @@ class _RentalPageState extends State { horizontal: 8, vertical: 4, ), - child: Text(_includeLabel(value)), + child: Text(RentalTableSpec.includeLabel(value)), ), ), ], @@ -280,8 +449,11 @@ class _RentalPageState extends State { }); }, options: [ - for (final option in _includeOptions) - ShadOption(value: option, child: Text(_includeLabel(option))), + for (final option in RentalTableSpec.defaultIncludeOptions) + ShadOption( + value: option, + child: Text(RentalTableSpec.includeLabel(option)), + ), ], ), ), @@ -306,7 +478,7 @@ class _RentalPageState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('대여 내역', style: theme.textTheme.h3), - Text('${filtered.length}건', style: theme.textTheme.muted), + Text('$totalItems건', style: theme.textTheme.muted), ], ), child: Column( @@ -314,13 +486,17 @@ class _RentalPageState extends State { children: [ SizedBox( height: 420, - child: filtered.isEmpty - ? const SuperportEmptyState( - title: '대여 데이터가 없습니다.', - description: '검색 조건을 조정해 다시 시도하세요.', + child: _isLoading && filtered.isEmpty + ? const Center(child: CircularProgressIndicator()) + : filtered.isEmpty + ? SuperportEmptyState( + title: _errorMessage != null + ? '데이터를 불러오지 못했습니다.' + : '대여 데이터가 없습니다.', + description: _errorMessage ?? '검색 조건을 조정해 다시 시도하세요.', ) : ShadTable.list( - header: _tableHeaders + header: RentalTableSpec.headers .map( (header) => ShadTableCell.header(child: Text(header)), @@ -338,9 +514,12 @@ class _RentalPageState extends State { ), ], columnSpanExtent: (index) => - const FixedTableSpanExtent(140), - rowSpanExtent: (index) => - const FixedTableSpanExtent(56), + const FixedTableSpanExtent( + RentalTableSpec.columnSpanWidth, + ), + rowSpanExtent: (index) => const FixedTableSpanExtent( + RentalTableSpec.rowSpanHeight, + ), onRowTap: (rowIndex) { setState(() { _selectedRecord = visibleRecords[rowIndex]; @@ -369,9 +548,11 @@ class _RentalPageState extends State { _currentPage = 1; }); _updateRoute(page: 1, pageSize: value); + _fetchTransactions(page: 1); }, options: [ - for (final option in _pageSizeOptions) + for (final option + in RentalTableSpec.pageSizeOptions) ShadOption( value: option, child: Text('$option개 / 페이지'), @@ -382,7 +563,7 @@ class _RentalPageState extends State { Row( children: [ Text( - '${filtered.length}건 · 페이지 $currentPage / $totalPages', + '$totalItems건 · 페이지 $currentPage / $totalPages', style: theme.textTheme.small, ), const SizedBox(width: 12), @@ -422,6 +603,27 @@ class _RentalPageState extends State { dateFormatter: _dateFormatter, currencyFormatter: _currencyFormatter, onEdit: () => _handleEdit(_selectedRecord!), + transitionsEnabled: _transitionsEnabled, + onSubmit: _canSubmit(_selectedRecord!) + ? () async => _submitRecord(_selectedRecord!) + : null, + onComplete: _canComplete(_selectedRecord!) + ? () async => _completeRecord(_selectedRecord!) + : null, + onApprove: _canApprove(_selectedRecord!) + ? () async => _approveRecord(_selectedRecord!) + : null, + onReject: _canReject(_selectedRecord!) + ? () async => _rejectRecord(_selectedRecord!) + : null, + onCancel: _canCancel(_selectedRecord!) + ? () async => _cancelRecord(_selectedRecord!) + : null, + canSubmit: !_isProcessing(_selectedRecord!.id) && !_isLoading, + canComplete: !_isProcessing(_selectedRecord!.id) && !_isLoading, + canApprove: !_isProcessing(_selectedRecord!.id) && !_isLoading, + canReject: !_isProcessing(_selectedRecord!.id) && !_isLoading, + canCancel: !_isProcessing(_selectedRecord!.id) && !_isLoading, ), ], ], @@ -441,7 +643,9 @@ class _RentalPageState extends State { record.transactionNumber.toLowerCase().contains(query) || record.writer.toLowerCase().contains(query) || record.customers.any( - (customer) => customer.toLowerCase().contains(query), + (customer) => + customer.name.toLowerCase().contains(query) || + customer.code.toLowerCase().contains(query), ) || record.items.any( (item) => item.product.toLowerCase().contains(query), @@ -461,8 +665,16 @@ class _RentalPageState extends State { return !due.isBefore(returnRange.start) && !due.isAfter(returnRange.end); }(); - final matchesWarehouse = - _appliedWarehouse == null || record.warehouse == _appliedWarehouse; + final matchesWarehouse = () { + if (_appliedWarehouseId != null) { + return record.warehouseId == _appliedWarehouseId; + } + if (_appliedWarehouseLabel != null && + _appliedWarehouseLabel!.isNotEmpty) { + return record.warehouse == _appliedWarehouseLabel; + } + return true; + }(); final matchesStatus = _appliedStatus == null || record.status == _appliedStatus; final matchesRentalType = @@ -521,7 +733,6 @@ class _RentalPageState extends State { final record = await _showRentalFormDialog(); if (record != null) { setState(() { - _records.insert(0, record); _selectedRecord = record; }); } @@ -531,23 +742,210 @@ class _RentalPageState extends State { final updated = await _showRentalFormDialog(initial: record); if (updated != null) { setState(() { - final index = _records.indexWhere( - (element) => element.number == record.number, - ); - if (index != -1) { - _records[index] = updated; - _selectedRecord = updated; - } + _selectedRecord = updated; }); } } + bool _isProcessing(int? id) { + if (id == null) { + return false; + } + return _processingTransactionIds.contains(id); + } + + bool _canSubmit(RentalRecord record) { + if (!_transitionsEnabled) { + return false; + } + final status = record.status.trim(); + return status.contains('대여') && !status.contains('반납'); + } + + bool _canComplete(RentalRecord record) { + if (!_transitionsEnabled) { + return false; + } + final status = record.status.trim(); + return status.contains('반납') || status.contains('대기'); + } + + bool _canApprove(RentalRecord record) { + if (!_transitionsEnabled) { + return false; + } + final normalized = record.status.replaceAll(RegExp(r'\s+'), ''); + return normalized.contains('반납대기'); + } + + bool _canReject(RentalRecord record) { + if (!_transitionsEnabled) { + return false; + } + final normalized = record.status.replaceAll(RegExp(r'\s+'), ''); + return normalized.contains('반납대기'); + } + + bool _canCancel(RentalRecord record) { + if (!_transitionsEnabled) { + return false; + } + final normalized = record.status.replaceAll(RegExp(r'\s+'), ''); + return normalized.contains('대여중') || normalized.contains('반납대기'); + } + + /// 실패 객체에서 사용자 메시지를 추출한다. + String _failureMessage(Object error, String fallbackMessage) { + final failure = Failure.from(error); + final description = failure.describe(); + if (description.isEmpty) { + return fallbackMessage; + } + return description; + } + + Future _submitRecord(RentalRecord record) async { + final controller = _controller; + final id = record.id; + if (controller == null || id == null) { + if (mounted) { + SuperportToast.error(context, '반납 요청 대상을 찾을 수 없습니다.'); + } + return; + } + + try { + await controller.submitTransaction(id); + if (!mounted) { + return; + } + SuperportToast.success(context, '반납 요청을 완료했습니다.'); + } catch (error) { + if (!mounted) { + return; + } + SuperportToast.error( + context, + _failureMessage(error, '반납 요청 처리에 실패했습니다. 다시 시도하세요.'), + ); + } + } + + Future _completeRecord(RentalRecord record) async { + final controller = _controller; + final id = record.id; + if (controller == null || id == null) { + if (mounted) { + SuperportToast.error(context, '완료 처리할 대여 데이터를 찾지 못했습니다.'); + } + return; + } + + try { + await controller.completeTransaction(id); + if (!mounted) { + return; + } + SuperportToast.success(context, '대여 완료 처리되었습니다.'); + } catch (error) { + if (!mounted) { + return; + } + SuperportToast.error( + context, + _failureMessage(error, '대여 완료 처리에 실패했습니다. 잠시 후 다시 시도하세요.'), + ); + } + } + + Future _approveRecord(RentalRecord record) async { + final controller = _controller; + final id = record.id; + if (controller == null || id == null) { + if (mounted) { + SuperportToast.error(context, '승인할 대여 데이터를 찾을 수 없습니다.'); + } + return; + } + + try { + await controller.approveTransaction(id); + if (!mounted) { + return; + } + SuperportToast.success(context, '반납 승인이 완료되었습니다.'); + } catch (error) { + if (!mounted) { + return; + } + SuperportToast.error( + context, + _failureMessage(error, '반납 승인 처리에 실패했습니다. 잠시 후 다시 시도하세요.'), + ); + } + } + + Future _rejectRecord(RentalRecord record) async { + final controller = _controller; + final id = record.id; + if (controller == null || id == null) { + if (mounted) { + SuperportToast.error(context, '반려할 대여 데이터를 찾을 수 없습니다.'); + } + return; + } + + try { + await controller.rejectTransaction(id); + if (!mounted) { + return; + } + SuperportToast.success(context, '반납이 반려되었습니다.'); + } catch (error) { + if (!mounted) { + return; + } + SuperportToast.error( + context, + _failureMessage(error, '반납 반려 처리에 실패했습니다. 다시 시도하세요.'), + ); + } + } + + Future _cancelRecord(RentalRecord record) async { + final controller = _controller; + final id = record.id; + if (controller == null || id == null) { + if (mounted) { + SuperportToast.error(context, '취소할 대여 데이터를 찾지 못했습니다.'); + } + return; + } + + try { + await controller.cancelTransaction(id); + if (!mounted) { + return; + } + SuperportToast.success(context, '대여 요청이 취소되었습니다.'); + } catch (error) { + if (!mounted) { + return; + } + SuperportToast.error( + context, + _failureMessage(error, '대여 취소 처리에 실패했습니다. 잠시 후 다시 시도하세요.'), + ); + } + } + void _applyFilters() { setState(() { _query = _pendingQuery.trim(); _appliedDateRange = _pendingDateRange; _appliedReturnRange = _pendingReturnRange; - _appliedWarehouse = _pendingWarehouse; + _appliedWarehouseId = _pendingWarehouseId; + _appliedWarehouseLabel = _pendingWarehouseLabel; _appliedStatus = _pendingStatus; _appliedRentalType = _pendingRentalType; _appliedIncludes = {..._pendingIncludes}; @@ -555,6 +953,7 @@ class _RentalPageState extends State { _refreshSelection(); }); _updateRoute(page: 1); + _fetchTransactions(page: 1); } void _resetFilters() { @@ -566,40 +965,48 @@ class _RentalPageState extends State { _appliedDateRange = null; _pendingReturnRange = null; _appliedReturnRange = null; - _pendingWarehouse = null; - _appliedWarehouse = null; + _pendingWarehouseId = null; + _appliedWarehouseId = null; + _pendingWarehouseLabel = null; + _appliedWarehouseLabel = null; _pendingStatus = null; _appliedStatus = null; _pendingRentalType = null; _appliedRentalType = null; _currentPage = 1; - _pageSize = _pageSizeOptions.first; + _pageSize = RentalTableSpec.pageSizeOptions.first; _sortField = _RentalSortField.processedAt; _sortAscending = false; - _pendingIncludes = {..._includeOptions}; - _appliedIncludes = {..._includeOptions}; + _pendingIncludes = {...RentalTableSpec.defaultIncludeOptions}; + _appliedIncludes = {...RentalTableSpec.defaultIncludeOptions}; _includeController ..value.clear() ..value.addAll(_pendingIncludes); _refreshSelection(); }); - _updateRoute(page: 1, pageSize: _pageSizeOptions.first); + _updateRoute(page: 1, pageSize: RentalTableSpec.pageSizeOptions.first); + _fetchTransactions(page: 1); } bool get _hasAppliedFilters => _query.isNotEmpty || _appliedDateRange != null || _appliedReturnRange != null || - _appliedWarehouse != null || + _appliedWarehouseId != null || + (_appliedWarehouseLabel != null && _appliedWarehouseLabel!.isNotEmpty) || _appliedStatus != null || _appliedRentalType != null || - !_setEquals(_appliedIncludes, _includeOptions.toSet()); + !_setEquals( + _appliedIncludes, + RentalTableSpec.defaultIncludeOptions.toSet(), + ); bool get _hasDirtyFilters => _pendingQuery.trim() != _query || !_isSameRange(_pendingDateRange, _appliedDateRange) || !_isSameRange(_pendingReturnRange, _appliedReturnRange) || - _pendingWarehouse != _appliedWarehouse || + _pendingWarehouseId != _appliedWarehouseId || + _pendingWarehouseLabel != _appliedWarehouseLabel || _pendingStatus != _appliedStatus || _pendingRentalType != _appliedRentalType || !_setEquals(_pendingIncludes, _appliedIncludes); @@ -617,6 +1024,7 @@ class _RentalPageState extends State { void _applyRouteParameters(Uri uri, {bool initialize = false}) { final params = uri.queryParameters; final query = params['q'] ?? ''; + final warehouseIdParam = int.tryParse(params['warehouse_id'] ?? ''); final warehouseParam = params['warehouse']; final statusParam = params['status']; final rentalTypeParam = params['rental_type']; @@ -633,32 +1041,39 @@ class _RentalPageState extends State { final pageSizeParam = int.tryParse(params['page_size'] ?? ''); final pageParam = int.tryParse(params['page'] ?? ''); - final warehouse = _warehouseOptions.contains(warehouseParam) + final warehouseId = warehouseIdParam; + final warehouseLabel = warehouseParam != null && warehouseParam.isNotEmpty ? warehouseParam : null; final status = _statusOptions.contains(statusParam) ? statusParam : null; - final rentalType = _rentalTypes.contains(rentalTypeParam) + final rentalType = RentalTableSpec.rentalTypes.contains(rentalTypeParam) ? rentalTypeParam : null; final includes = includesParam == null || includesParam.isEmpty - ? {..._includeOptions} + ? {...RentalTableSpec.defaultIncludeOptions} : includesParam .split(',') .map((token) => token.trim()) - .where((token) => _includeOptions.contains(token)) + .where( + (token) => + RentalTableSpec.defaultIncludeOptions.contains(token), + ) .toSet(); final pageSize = - (pageSizeParam != null && _pageSizeOptions.contains(pageSizeParam)) + (pageSizeParam != null && + RentalTableSpec.pageSizeOptions.contains(pageSizeParam)) ? pageSizeParam - : _pageSizeOptions.first; + : RentalTableSpec.pageSizeOptions.first; final page = (pageParam != null && pageParam > 0) ? pageParam : 1; void assign() { _pendingQuery = query; _query = query; _searchController.text = query; - _pendingWarehouse = warehouse; - _appliedWarehouse = warehouse; + _pendingWarehouseId = warehouseId; + _appliedWarehouseId = warehouseId; + _pendingWarehouseLabel = warehouseLabel; + _appliedWarehouseLabel = warehouseLabel; _pendingStatus = status; _appliedStatus = status; _pendingRentalType = rentalType; @@ -667,7 +1082,9 @@ class _RentalPageState extends State { _appliedDateRange = dateRange; _pendingReturnRange = returnRange; _appliedReturnRange = returnRange; - _pendingIncludes = includes.isEmpty ? {..._includeOptions} : includes; + _pendingIncludes = includes.isEmpty + ? {...RentalTableSpec.defaultIncludeOptions} + : includes; _appliedIncludes = {..._pendingIncludes}; _includeController ..value.clear() @@ -685,6 +1102,7 @@ class _RentalPageState extends State { } setState(assign); + _fetchTransactions(page: page); } void _goToPage(int page) { @@ -696,15 +1114,35 @@ class _RentalPageState extends State { _currentPage = target; }); _updateRoute(page: target); + _fetchTransactions(page: target); } void _refreshSelection() { final filtered = _filteredRecords; - if (_selectedRecord != null && !filtered.contains(_selectedRecord)) { - _selectedRecord = filtered.isEmpty ? null : filtered.first; - } else if (_selectedRecord == null && filtered.isNotEmpty) { - _selectedRecord = filtered.first; + if (filtered.isEmpty) { + _selectedRecord = null; + return; } + + final current = _selectedRecord; + if (current != null) { + RentalRecord? matched; + if (current.id != null) { + matched = filtered.firstWhere( + (element) => element.id == current.id, + orElse: () => filtered.first, + ); + } else { + matched = filtered.firstWhere( + (element) => element.transactionNumber == current.transactionNumber, + orElse: () => filtered.first, + ); + } + _selectedRecord = matched; + return; + } + + _selectedRecord = filtered.first; } void _updateRoute({int? page, int? pageSize}) { @@ -716,8 +1154,15 @@ class _RentalPageState extends State { if (_query.isNotEmpty) { params['q'] = _query; } - if (_appliedWarehouse != null && _appliedWarehouse!.isNotEmpty) { - params['warehouse'] = _appliedWarehouse!; + if (_appliedWarehouseId != null) { + params['warehouse_id'] = _appliedWarehouseId.toString(); + if (_appliedWarehouseLabel != null && + _appliedWarehouseLabel!.isNotEmpty) { + params['warehouse'] = _appliedWarehouseLabel!; + } + } else if (_appliedWarehouseLabel != null && + _appliedWarehouseLabel!.isNotEmpty) { + params['warehouse'] = _appliedWarehouseLabel!; } if (_appliedStatus != null && _appliedStatus!.isNotEmpty) { params['status'] = _appliedStatus!; @@ -735,7 +1180,10 @@ class _RentalPageState extends State { params['return_start'] = _formatDateParam(returnRange.start); params['return_end'] = _formatDateParam(returnRange.end); } - if (!_setEquals(_appliedIncludes, _includeOptions.toSet())) { + if (!_setEquals( + _appliedIncludes, + RentalTableSpec.defaultIncludeOptions.toSet(), + )) { params['include'] = _appliedIncludes.join(','); } final sortParam = _encodeSortField(_sortField); @@ -748,7 +1196,7 @@ class _RentalPageState extends State { if (targetPage > 1) { params['page'] = targetPage.toString(); } - if (targetPageSize != _pageSizeOptions.first) { + if (targetPageSize != RentalTableSpec.pageSizeOptions.first) { params['page_size'] = targetPageSize.toString(); } @@ -848,17 +1296,6 @@ class _RentalPageState extends State { return true; } - String _includeLabel(String value) { - switch (value) { - case 'lines': - return '라인 포함'; - case 'customers': - return '고객 포함'; - default: - return value; - } - } - String _sortLabel(_RentalSortField field) { return switch (field) { _RentalSortField.processedAt => '처리일자', @@ -886,7 +1323,13 @@ class _RentalPageState extends State { } String _transactionTypeForRental(String rentalType) { - return rentalType == _rentalTypes.last + final lookup = rentalType == RentalTableSpec.rentalTypes.last + ? _returnTransactionType + : _rentTransactionType; + if (lookup != null) { + return lookup.name; + } + return rentalType == RentalTableSpec.rentalTypes.last ? _rentalTransactionTypeReturn : _rentalTransactionTypeRent; } @@ -897,25 +1340,74 @@ class _RentalPageState extends State { initial?.processedAt ?? DateTime.now(), ); final returnDue = ValueNotifier(initial?.returnDueDate); - final warehouseController = TextEditingController( - text: initial?.warehouse ?? _warehouseOptions.first, - ); + final initialWarehouse = initial?.raw?.warehouse; + final int? initialWarehouseId = + initialWarehouse?.id ?? initial?.warehouseId; + final String? initialWarehouseName = + initialWarehouse?.name ?? initial?.warehouse; + final String? initialWarehouseCode = initialWarehouse?.code; + InventoryWarehouseOption? warehouseSelection; + if (initialWarehouseId != null && initialWarehouseName != null) { + warehouseSelection = InventoryWarehouseOption( + id: initialWarehouseId, + code: initialWarehouseCode ?? initialWarehouseName, + name: initialWarehouseName, + ); + } final statusValue = ValueNotifier( initial?.status ?? _statusOptions.first, ); final rentalTypeValue = ValueNotifier( - initial?.rentalType ?? _rentalTypes.first, + initial?.rentalType ?? RentalTableSpec.rentalTypes.first, ); + InventoryEmployeeSuggestion? writerSelection; + final initialWriter = initial?.raw?.createdBy; + if (initialWriter != null) { + writerSelection = InventoryEmployeeSuggestion( + id: initialWriter.id, + employeeNo: initialWriter.employeeNo, + name: initialWriter.name, + ); + } else if (initial?.writerId != null && + (initial?.writer ?? '').isNotEmpty) { + writerSelection = InventoryEmployeeSuggestion( + id: initial!.writerId!, + employeeNo: initial.writer, + name: initial.writer, + ); + } + String writerLabel(InventoryEmployeeSuggestion? suggestion) { + if (suggestion == null) { + return ''; + } + return '${suggestion.name} (${suggestion.employeeNo})'; + } + final writerController = TextEditingController( - text: initial?.writer ?? '박대여', + text: writerLabel(writerSelection), ); final remarkController = TextEditingController(text: initial?.remark ?? ''); - final customerController = ShadSelectController( - initialValue: (initial?.customers ?? const []).toSet(), - ); - if (customerController.value.isEmpty && _customerOptions.isNotEmpty) { - customerController.value.add(_customerOptions.first); - } + final initialCustomers = initial?.customers ?? const []; + final Set initialCustomerIds = { + for (final customer in initialCustomers) customer.customerId, + }; + final Map customerDraftMap = { + for (final customer in initialCustomers) + customer.customerId: _RentalSelectedCustomerDraft( + option: InventoryCustomerOption( + id: customer.customerId, + code: customer.code, + name: customer.name, + industry: '-', + region: '-', + ), + linkId: customer.id, + note: customer.note, + ), + }; + List<_RentalSelectedCustomerDraft> customerSelection = customerDraftMap + .values + .toList(growable: false); final transactionTypeController = TextEditingController( text: _transactionTypeForRental(rentalTypeValue.value), ); @@ -928,13 +1420,15 @@ class _RentalPageState extends State { [_RentalLineItemDraft.empty()]; RentalRecord? result; - String customerSearchQuery = ''; String? writerError; String? customerError; String? warehouseError; String? statusError; String? headerNotice; - void Function(VoidCallback fn)? refreshForm; + StateSetter? refreshForm; + StateSetter? refreshCancelAction; + StateSetter? refreshSaveAction; + var isSaving = false; final lineErrors = { for (final draft in drafts) draft: _RentalLineItemErrors.empty(), @@ -942,12 +1436,27 @@ class _RentalPageState extends State { final navigator = Navigator.of(context); - void handleSubmit() { + void updateSaving(bool next) { + isSaving = next; + refreshForm?.call(() {}); + refreshCancelAction?.call(() {}); + refreshSaveAction?.call(() {}); + } + + Future handleSubmit() async { + if (isSaving) { + return; + } + final validation = _validateRentalForm( writerController: writerController, - warehouseValue: warehouseController.text, + writerSelection: writerSelection, + requireWriterSelection: initial == null, + warehouseSelection: warehouseSelection, statusValue: statusValue.value, - customerController: customerController, + selectedCustomers: customerSelection + .map((draft) => draft.option) + .toList(growable: false), drafts: drafts, lineErrors: lineErrors, ); @@ -963,42 +1472,195 @@ class _RentalPageState extends State { SuperportToast.error(context, '입력 오류를 확인하고 다시 시도하세요.'); return; } - final items = drafts + final controller = _controller; + if (controller == null) { + SuperportToast.error(context, '대여 컨트롤러를 찾을 수 없습니다.'); + return; + } + + final selectedLookup = + rentalTypeValue.value == RentalTableSpec.rentalTypes.last + ? (_returnTransactionType ?? controller.returnTransactionType) + : (_rentTransactionType ?? controller.rentTransactionType); + if (selectedLookup == null) { + SuperportToast.error(context, '대여/반납 트랜잭션 유형 정보를 불러오지 못했습니다.'); + return; + } + + final statusItem = _statusLookup[statusValue.value]; + if (statusItem == null) { + SuperportToast.error(context, '선택한 상태 정보를 확인할 수 없습니다.'); + return; + } + + final warehouseId = warehouseSelection?.id ?? initialWarehouseId; + if (warehouseId == null) { + SuperportToast.error(context, '창고 정보를 확인할 수 없습니다.'); + return; + } + + final createdById = writerSelection?.id ?? initial?.writerId; + if (initial == null && createdById == null) { + SuperportToast.error(context, '작성자 정보를 확인할 수 없습니다.'); + return; + } + + final remarkText = remarkController.text.trim(); + final remarkValue = remarkText.isEmpty ? null : remarkText; + final transactionId = initial?.id; + final initialRecord = initial; + + final lineDrafts = []; + for (var index = 0; index < drafts.length; index++) { + final draft = drafts[index]; + final productId = draft.productId ?? draft.suggestion?.id; + if (productId == null) { + continue; + } + final quantity = int.tryParse(draft.quantity.text.trim()) ?? 0; + final unitPrice = _parseCurrency(draft.price.text); + final noteText = draft.remark.text.trim(); + lineDrafts.add( + TransactionLineDraft( + id: draft.lineId, + lineNo: index + 1, + productId: productId, + quantity: quantity, + unitPrice: unitPrice, + note: noteText.isEmpty ? null : noteText, + ), + ); + } + + if (lineDrafts.isEmpty) { + SuperportToast.error(context, '최소 1개 이상의 품목을 선택하세요.'); + return; + } + + final customerDrafts = customerSelection .map( - (draft) => RentalLineItem( - product: draft.product.text, - manufacturer: draft.manufacturer.text, - unit: draft.unit.text, - quantity: - int.tryParse( - draft.quantity.text.trim().isEmpty - ? '0' - : draft.quantity.text.trim(), - ) ?? - 0, - price: _parseCurrency(draft.price.text), - remark: draft.remark.text, + (draft) => TransactionCustomerDraft( + id: draft.linkId, + customerId: draft.option.id, + note: draft.note, ), ) - .toList(); - items.sort((a, b) => a.product.compareTo(b.product)); - result = RentalRecord( - number: initial?.number ?? _generateRentalNumber(processedAt.value), - transactionNumber: - initial?.transactionNumber ?? - _generateTransactionNumber(processedAt.value), - transactionType: _transactionTypeForRental(rentalTypeValue.value), - processedAt: processedAt.value, - warehouse: warehouseController.text, - status: statusValue.value, - rentalType: rentalTypeValue.value, - returnDueDate: returnDue.value, - writer: writerController.text, - remark: remarkController.text, - customers: customerController.value.toList(), - items: items, - ); - navigator.pop(); + .toList(growable: false); + + if (customerDrafts.isEmpty) { + SuperportToast.error(context, '최소 1개의 고객사를 선택하세요.'); + return; + } + + try { + updateSaving(true); + + if (initialRecord != null) { + if (transactionId == null) { + updateSaving(false); + SuperportToast.error(context, '수정 대상 ID를 확인할 수 없습니다.'); + return; + } + final updated = await controller.updateTransaction( + transactionId, + StockTransactionUpdateInput( + transactionStatusId: statusItem.id, + note: remarkValue, + expectedReturnDate: returnDue.value, + ), + refreshAfter: false, + ); + result = updated; + final currentLines = + initialRecord.raw?.lines ?? const []; + final currentCustomers = + initialRecord.raw?.customers ?? + const []; + final linePlan = _detailSyncService.buildLinePlan( + drafts: lineDrafts, + currentLines: currentLines, + ); + final customerPlan = _detailSyncService.buildCustomerPlan( + drafts: customerDrafts, + currentCustomers: currentCustomers, + ); + if (linePlan.hasChanges) { + await controller.syncTransactionLines(transactionId, linePlan); + } + if (customerPlan.hasChanges) { + await controller.syncTransactionCustomers( + transactionId, + customerPlan, + ); + } + await controller.refresh(); + updateSaving(false); + if (!mounted) { + return; + } + SuperportToast.success(context, '대여 정보가 수정되었습니다.'); + navigator.pop(); + return; + } + + if (createdById == null) { + updateSaving(false); + SuperportToast.error(context, '작성자 선택이 필요합니다.'); + return; + } + + final createLines = lineDrafts + .map( + (draft) => TransactionLineCreateInput( + lineNo: draft.lineNo, + productId: draft.productId, + quantity: draft.quantity, + unitPrice: draft.unitPrice, + note: draft.note, + ), + ) + .toList(growable: false); + + final createCustomers = customerDrafts + .map( + (draft) => TransactionCustomerCreateInput( + customerId: draft.customerId, + note: draft.note, + ), + ) + .toList(growable: false); + + final transactionTypeId = selectedLookup.id; + final created = await controller.createTransaction( + StockTransactionCreateInput( + transactionTypeId: transactionTypeId, + transactionStatusId: statusItem.id, + warehouseId: warehouseId, + transactionDate: processedAt.value, + createdById: createdById, + note: remarkValue, + expectedReturnDate: returnDue.value, + lines: createLines, + customers: createCustomers, + ), + ); + result = created; + updateSaving(false); + if (!mounted) { + return; + } + SuperportToast.success(context, '대여가 등록되었습니다.'); + navigator.pop(); + } catch (error) { + updateSaving(false); + if (!mounted) { + return; + } + SuperportToast.error( + context, + _failureMessage(error, '저장 중 오류가 발생했습니다. 잠시 후 다시 시도하세요.'), + ); + } } await showSuperportDialog( @@ -1008,11 +1670,30 @@ class _RentalPageState extends State { constraints: const BoxConstraints(maxWidth: 900, maxHeight: 760), onSubmit: handleSubmit, actions: [ - ShadButton.ghost( - onPressed: () => navigator.pop(), - child: const Text('취소'), + StatefulBuilder( + builder: (context, setState) { + refreshCancelAction = setState; + return ShadButton.ghost( + onPressed: isSaving ? null : () => navigator.pop(), + child: const Text('취소'), + ); + }, + ), + StatefulBuilder( + builder: (context, setState) { + refreshSaveAction = setState; + return ShadButton( + onPressed: isSaving ? null : () => handleSubmit(), + child: isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('저장'), + ); + }, ), - ShadButton(onPressed: handleSubmit, child: const Text('저장')), ], body: StatefulBuilder( builder: (context, setState) { @@ -1035,8 +1716,9 @@ class _RentalPageState extends State { child: SuperportDatePickerButton( value: processedAt.value, dateFormat: _dateFormatter, - firstDate: DateTime(2020), - lastDate: DateTime(2030), + firstDate: RentalTableSpec.dateRangeFirstDate, + lastDate: RentalTableSpec.dateRangeLastDate, + enabled: initial == null, onChanged: (date) { processedAt.value = date; setState(() {}); @@ -1061,7 +1743,7 @@ class _RentalPageState extends State { } }, enabled: initial?.status != '완료', - options: _rentalTypes + options: RentalTableSpec.rentalTypes .map( (type) => ShadOption(value: type, child: Text(type)), @@ -1076,28 +1758,17 @@ class _RentalPageState extends State { label: '창고', required: true, errorText: warehouseError, - child: ShadSelect( - initialValue: warehouseController.text, - selectedOptionBuilder: (context, value) => - Text(value), - onChanged: (value) { - if (value != null) { - warehouseController.text = value; - setState(() { - if (warehouseError != null) { - warehouseError = null; - } - }); - } + child: InventoryWarehouseSelectField( + initialWarehouseId: initialWarehouseId, + enabled: initial == null, + onChanged: (option) { + warehouseSelection = option; + setState(() { + if (warehouseError != null) { + warehouseError = null; + } + }); }, - options: _warehouseOptions - .map( - (option) => ShadOption( - value: option, - child: Text(option), - ), - ) - .toList(), ), ), ), @@ -1148,106 +1819,52 @@ class _RentalPageState extends State { width: 360, child: _FormFieldLabel( label: '대여 고객사', - child: Builder( - builder: (context) { - final filtered = InventoryCustomerCatalog.filter( - customerSearchQuery, - ); - final theme = ShadTheme.of(context); - final hasResults = filtered.isNotEmpty; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadSelect.multipleWithSearch( - controller: customerController, - placeholder: const Text('고객사 선택'), - searchPlaceholder: const Text( - '고객사 이름 또는 코드 검색', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InventoryCustomerMultiSelectField( + initialCustomerIds: initialCustomerIds, + enabled: initial?.status != '완료', + onChanged: (options) { + final nextMap = + {}; + for (final option in options) { + final existing = customerDraftMap[option.id]; + if (existing != null) { + nextMap[option.id] = existing; + } else { + nextMap[option.id] = + _RentalSelectedCustomerDraft( + option: option, + ); + } + } + customerDraftMap + ..clear() + ..addAll(nextMap); + customerSelection = customerDraftMap.values + .toList(growable: false); + if (customerError != null && + options.isNotEmpty) { + setState(() { + customerError = null; + }); + } else { + setState(() {}); + } + }, + ), + if (customerError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + customerError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, ), - searchInputLeading: const Icon( - lucide.LucideIcons.search, - size: 16, - ), - clearSearchOnClose: true, - closeOnSelect: false, - options: [ - for (final customer in filtered) - ShadOption( - value: customer.name, - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - customer.name, - style: theme.textTheme.p, - ), - const SizedBox(height: 4), - Text( - '${customer.code} · ${customer.industry} · ${customer.region}', - style: theme.textTheme.muted - .copyWith(fontSize: 12), - ), - ], - ), - ), - ], - footer: hasResults - ? null - : buildEmptySearchResult(theme.textTheme), - onSearchChanged: (query) { - setState(() { - customerSearchQuery = query; - }); - }, - selectedOptionsBuilder: (context, values) { - if (values.isEmpty) { - return const Text('선택된 고객사가 없습니다'); - } - return Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final value in values) - ShadBadge( - child: Padding( - padding: - const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - child: Text( - InventoryCustomerCatalog.displayLabel( - value, - ), - ), - ), - ), - ], - ); - }, - onChanged: (values) { - setState(() { - if (customerError != null && - values.isNotEmpty) { - customerError = null; - } - }); - }, ), - if (customerError != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - customerError!, - style: theme.textTheme.small.copyWith( - color: theme.colorScheme.destructive, - ), - ), - ), - ], - ); - }, + ), + ], ), ), ), @@ -1260,7 +1877,7 @@ class _RentalPageState extends State { dateFormat: _dateFormatter, placeholder: '선택', firstDate: processedAt.value, - lastDate: DateTime(2030), + lastDate: RentalTableSpec.dateRangeLastDate, initialDate: processedAt.value, enabled: initial?.status != '완료', onChanged: (date) { @@ -1277,15 +1894,36 @@ class _RentalPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ShadInput( + InventoryEmployeeAutocompleteField( controller: writerController, - onChanged: (_) { + initialSuggestion: writerSelection, + enabled: initial == null, + onSuggestionSelected: (suggestion) { + writerSelection = suggestion; if (writerError != null) { setState(() { writerError = null; }); } }, + onChanged: () { + if (initial == null) { + final currentText = writerController.text + .trim(); + final selectedLabel = writerLabel( + writerSelection, + ); + if (currentText.isEmpty || + currentText != selectedLabel) { + writerSelection = null; + } + if (writerError != null) { + setState(() { + writerError = null; + }); + } + } + }, ), if (writerError != null) Padding( @@ -1333,6 +1971,11 @@ class _RentalPageState extends State { label: '총 금액', value: _currencyFormatter.format(summary.totalAmount), ), + _RentalSummaryBadge( + icon: lucide.LucideIcons.users, + label: '고객사 수', + value: '${customerSelection.length}곳', + ), ], ), const SizedBox(height: 16), @@ -1420,7 +2063,6 @@ class _RentalPageState extends State { for (final draft in drafts) { draft.dispose(); } - warehouseController.dispose(); statusValue.dispose(); rentalTypeValue.dispose(); writerController.dispose(); @@ -1428,34 +2070,9 @@ class _RentalPageState extends State { transactionTypeController.dispose(); processedAt.dispose(); returnDue.dispose(); - customerController.dispose(); return result; } - - static String _generateRentalNumber(DateTime date) { - final stamp = DateFormat('yyyyMMdd-HHmmss').format(date); - return 'RENT-$stamp'; - } - - static String _generateTransactionNumber(DateTime date) { - final stamp = DateFormat('yyyyMMdd-HHmmss').format(date); - return 'TX-$stamp'; - } - - static const _tableHeaders = [ - '번호', - '처리일자', - '창고', - '대여구분', - '트랜잭션번호', - '상태', - '반납예정일', - '고객수', - '품목수', - '총수량', - '비고', - ]; } enum _RentalSortField { @@ -1475,12 +2092,34 @@ class _RentalDetailCard extends StatelessWidget { required this.dateFormatter, required this.currencyFormatter, required this.onEdit, + this.transitionsEnabled = true, + this.onSubmit, + this.onComplete, + this.onApprove, + this.onReject, + this.onCancel, + this.canSubmit = true, + this.canComplete = true, + this.canApprove = true, + this.canReject = true, + this.canCancel = true, }); final RentalRecord record; final DateFormat dateFormatter; final NumberFormat currencyFormatter; final VoidCallback onEdit; + final bool transitionsEnabled; + final Future Function()? onSubmit; + final Future Function()? onComplete; + final Future Function()? onApprove; + final Future Function()? onReject; + final Future Function()? onCancel; + final bool canSubmit; + final bool canComplete; + final bool canApprove; + final bool canReject; + final bool canCancel; @override Widget build(BuildContext context) { @@ -1491,10 +2130,60 @@ class _RentalDetailCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('선택된 대여 상세', style: theme.textTheme.h3), - ShadButton.outline( - leading: const Icon(lucide.LucideIcons.pencil, size: 16), - onPressed: onEdit, - child: const Text('수정'), + Wrap( + spacing: 8, + children: [ + if (onSubmit != null) + ShadButton.outline( + onPressed: canSubmit + ? () { + onSubmit?.call(); + } + : null, + child: const Text('반납 요청'), + ), + if (onApprove != null) + ShadButton.outline( + onPressed: canApprove + ? () { + onApprove?.call(); + } + : null, + child: const Text('승인'), + ), + if (onReject != null) + ShadButton.outline( + onPressed: canReject + ? () { + onReject?.call(); + } + : null, + child: const Text('반려'), + ), + if (onCancel != null) + ShadButton.outline( + onPressed: canCancel + ? () { + onCancel?.call(); + } + : null, + child: const Text('취소'), + ), + if (onComplete != null) + ShadButton.outline( + onPressed: canComplete + ? () { + onComplete?.call(); + } + : null, + child: const Text('대여 완료'), + ), + ShadButton.outline( + leading: const Icon(lucide.LucideIcons.pencil, size: 16), + onPressed: onEdit, + child: const Text('수정'), + ), + ], ), ], ), @@ -1505,6 +2194,15 @@ class _RentalDetailCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (!transitionsEnabled) ...[ + ShadBadge.outline( + child: Text( + '재고 상태 전이가 비활성화된 상태입니다.', + style: theme.textTheme.small, + ), + ), + const SizedBox(height: 16), + ], Wrap( spacing: 12, runSpacing: 12, @@ -1546,7 +2244,7 @@ class _RentalDetailCard extends StatelessWidget { horizontal: 10, vertical: 6, ), - child: Text(customer), + child: Text('${customer.name} · ${customer.code}'), ), ), ], @@ -1638,8 +2336,10 @@ class _RentalLineItemRow extends StatelessWidget { productFocusNode: draft.productFocus, manufacturerController: draft.manufacturer, unitController: draft.unit, - onCatalogMatched: (catalog) { - draft.catalogMatch = catalog; + initialSuggestion: draft.suggestion, + onSuggestionSelected: (suggestion) { + draft.suggestion = suggestion; + draft.productId = suggestion?.id; onFieldChanged(_RentalLineField.product); }, onChanged: () => onFieldChanged(_RentalLineField.product), @@ -1734,6 +2434,8 @@ class _RentalLineItemDraft { required this.quantity, required this.price, required this.remark, + this.lineId, + this.productId, }); final TextEditingController product; @@ -1743,7 +2445,9 @@ class _RentalLineItemDraft { final TextEditingController quantity; final TextEditingController price; final TextEditingController remark; - InventoryProductCatalogItem? catalogMatch; + final int? lineId; + int? productId; + InventoryProductSuggestion? suggestion; factory _RentalLineItemDraft.empty() { return _RentalLineItemDraft._( @@ -1754,6 +2458,8 @@ class _RentalLineItemDraft { quantity: TextEditingController(text: '1'), price: TextEditingController(text: '0'), remark: TextEditingController(), + lineId: null, + productId: null, ); } @@ -1766,8 +2472,16 @@ class _RentalLineItemDraft { quantity: TextEditingController(text: '${item.quantity}'), price: TextEditingController(text: item.price.toStringAsFixed(0)), remark: TextEditingController(text: item.remark), + lineId: item.id, + productId: item.productId, + ); + draft.suggestion = InventoryProductSuggestion( + id: item.productId, + code: item.productCode, + name: item.product, + vendorName: item.manufacturer, + unitName: item.unit, ); - draft.catalogMatch = InventoryProductCatalog.match(item.product); return draft; } @@ -1816,49 +2530,22 @@ class _RentalLineItemErrors { } } -class RentalRecord { - RentalRecord({ - required this.number, - required this.transactionNumber, - required this.transactionType, - required this.processedAt, - required this.warehouse, - required this.status, - required this.rentalType, - required this.returnDueDate, - required this.writer, - required this.remark, - required this.customers, - required this.items, - }); +class _RentalSelectedCustomerDraft { + _RentalSelectedCustomerDraft({required this.option, this.linkId, this.note}); - final String number; - final String transactionNumber; - final String transactionType; - final DateTime processedAt; - final String warehouse; - final String status; - final String rentalType; - final DateTime? returnDueDate; - final String writer; - final String remark; - final List customers; - final List items; - - int get customerCount => customers.length; - int get itemCount => items.length; - int get totalQuantity => - items.fold(0, (sum, item) => sum + item.quantity); - double get totalAmount => - items.fold(0, (sum, item) => sum + (item.price * item.quantity)); + final InventoryCustomerOption option; + final int? linkId; + final String? note; } /// 대여 폼의 필수 값 및 품목 조건을 검증한다. _RentalFormValidation _validateRentalForm({ required TextEditingController writerController, - required String warehouseValue, + required InventoryEmployeeSuggestion? writerSelection, + required bool requireWriterSelection, + required InventoryWarehouseOption? warehouseSelection, required String statusValue, - required ShadSelectController customerController, + required List selectedCustomers, required List<_RentalLineItemDraft> drafts, required Map<_RentalLineItemDraft, _RentalLineItemErrors> lineErrors, }) { @@ -1869,12 +2556,18 @@ _RentalFormValidation _validateRentalForm({ String? statusError; String? headerNotice; - if (writerController.text.trim().isEmpty) { + final writerText = writerController.text.trim(); + if (writerText.isEmpty) { writerError = '작성자를 입력하세요.'; isValid = false; } - if (warehouseValue.trim().isEmpty) { + if (requireWriterSelection && writerSelection == null) { + writerError = '작성자는 자동완성에서 선택하세요.'; + isValid = false; + } + + if (warehouseSelection == null) { warehouseError = '창고를 선택하세요.'; isValid = false; } @@ -1884,7 +2577,7 @@ _RentalFormValidation _validateRentalForm({ isValid = false; } - if (customerController.value.isEmpty) { + if (selectedCustomers.isEmpty) { customerError = '최소 1개의 고객사를 선택하세요.'; isValid = false; } @@ -1900,13 +2593,13 @@ _RentalFormValidation _validateRentalForm({ errors.product = '제품을 입력하세요.'; hasLineError = true; isValid = false; - } else if (draft.catalogMatch == null) { + } else if (draft.suggestion == null) { errors.product = '제품은 목록에서 선택하세요.'; hasLineError = true; isValid = false; } - final productKey = draft.catalogMatch?.code ?? productText.toLowerCase(); + final productKey = draft.suggestion?.code ?? productText.toLowerCase(); if (productKey.isNotEmpty && !seenProductKeys.add(productKey)) { errors.product = '동일 제품이 중복되었습니다.'; hasLineError = true; @@ -2033,104 +2726,6 @@ class _RentalSummaryBadge extends StatelessWidget { } } -class RentalLineItem { - RentalLineItem({ - required this.product, - required this.manufacturer, - required this.unit, - required this.quantity, - required this.price, - required this.remark, - }); - - final String product; - final String manufacturer; - final String unit; - final int quantity; - final double price; - final String remark; -} - -final List _mockRentalRecords = [ - RentalRecord( - number: 'RENT-20240305-001', - transactionNumber: 'TX-20240305-030', - transactionType: _rentalTransactionTypeRent, - processedAt: DateTime(2024, 3, 5), - warehouse: '서울 1창고', - status: '대여중', - rentalType: '대여', - returnDueDate: DateTime(2024, 3, 12), - writer: '박대여', - remark: '장기 대여', - customers: ['슈퍼포트 파트너'], - items: [ - RentalLineItem( - product: 'XR-5000', - manufacturer: '슈퍼벤더', - unit: 'EA', - quantity: 10, - price: 120000, - remark: '검수 예정', - ), - RentalLineItem( - product: 'XR-5002', - manufacturer: '슈퍼벤더', - unit: 'EA', - quantity: 5, - price: 110000, - remark: '', - ), - ], - ), - RentalRecord( - number: 'RENT-20240308-004', - transactionNumber: 'TX-20240308-014', - transactionType: _rentalTransactionTypeRent, - processedAt: DateTime(2024, 3, 8), - warehouse: '부산 센터', - status: '반납대기', - rentalType: '대여', - returnDueDate: DateTime(2024, 3, 15), - writer: '이반납', - remark: '-', - customers: ['그린에너지', '테크솔루션'], - items: [ - RentalLineItem( - product: 'Eco-200', - manufacturer: '그린텍', - unit: 'EA', - quantity: 8, - price: 145000, - remark: '', - ), - ], - ), - RentalRecord( - number: 'RENT-20240312-006', - transactionNumber: 'TX-20240312-021', - transactionType: _rentalTransactionTypeReturn, - processedAt: DateTime(2024, 3, 12), - warehouse: '대전 물류', - status: '완료', - rentalType: '반납', - returnDueDate: DateTime(2024, 3, 12), - writer: '최관리', - remark: '정상 반납', - customers: ['에이치솔루션'], - items: [ - RentalLineItem( - product: 'Delta-One', - manufacturer: '델타', - unit: 'SET', - quantity: 4, - price: 480000, - remark: '', - ), - ], - ), -]; - class _DetailChip extends StatelessWidget { const _DetailChip({required this.label, required this.value}); diff --git a/lib/features/login/presentation/pages/login_page.dart b/lib/features/login/presentation/pages/login_page.dart index 24ee948..e64a360 100644 --- a/lib/features/login/presentation/pages/login_page.dart +++ b/lib/features/login/presentation/pages/login_page.dart @@ -1,10 +1,19 @@ import 'dart:math' as math; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../../core/constants/app_sections.dart'; +import '../../../../core/network/failure.dart'; +import '../../../../core/permissions/permission_manager.dart'; +import '../../../../core/permissions/permission_resources.dart'; +import '../../../masters/group/domain/entities/group.dart'; +import '../../../masters/group/domain/repositories/group_repository.dart'; +import '../../../masters/group_permission/application/permission_synchronizer.dart'; +import '../../../masters/group_permission/domain/repositories/group_permission_repository.dart'; /// Superport 로그인 화면. 간단한 유효성 검증 후 대시보드로 이동한다. class LoginPage extends StatefulWidget { @@ -58,6 +67,24 @@ class _LoginPageState extends State { } if (!mounted) return; + try { + await _synchronizePermissions(); + } catch (error) { + if (!mounted) return; + final failure = Failure.from(error); + final description = failure.describe(); + final message = description.isEmpty + ? '권한 정보를 불러오지 못했습니다. 잠시 후 다시 시도하세요.' + : description; + setState(() { + errorMessage = message; + isLoading = false; + }); + return; + } + + if (!mounted) return; + setState(() => isLoading = false); context.go(dashboardRoutePath); } @@ -222,6 +249,13 @@ class _LoginPageState extends State { ], ), ), + if (kDebugMode) ...[ + const SizedBox(height: 12), + ShadButton.ghost( + onPressed: isLoading ? null : _handleTestLogin, + child: const Text('테스트 로그인'), + ), + ], ], ), ); @@ -250,4 +284,73 @@ class _LoginPageState extends State { ], ); } + + /// 디버그 모드에서 모든 권한을 부여하고 즉시 대시보드로 이동한다. + void _handleTestLogin() { + final manager = PermissionScope.of(context); + manager.clearServerPermissions(); + + final allActions = PermissionAction.values.toSet(); + final overrides = >{}; + final dashboardResource = PermissionResources.normalize(dashboardRoutePath); + if (dashboardResource.isNotEmpty) { + overrides[dashboardResource] = allActions; + } + for (final page in allAppPages) { + final resource = PermissionResources.normalize(page.path); + if (resource.isEmpty) { + continue; + } + overrides[resource] = allActions; + } + manager.updateOverrides(overrides); + + setState(() { + errorMessage = null; + isLoading = false; + }); + if (!mounted) { + return; + } + context.go(dashboardRoutePath); + } + + Future _synchronizePermissions() async { + final manager = PermissionScope.of(context); + manager.clearServerPermissions(); + + final groupRepository = GetIt.I(); + final defaultGroups = await groupRepository.list( + page: 1, + pageSize: 1, + isDefault: true, + ); + Group? targetGroup = _firstGroupWithId(defaultGroups.items); + + if (targetGroup == null) { + final fallbackGroups = await groupRepository.list(page: 1, pageSize: 1); + targetGroup = _firstGroupWithId(fallbackGroups.items); + } + + if (targetGroup?.id == null) { + return; + } + + final permissionRepository = GetIt.I(); + final synchronizer = PermissionSynchronizer( + repository: permissionRepository, + manager: manager, + ); + final groupId = targetGroup!.id!; + await synchronizer.syncForGroup(groupId); + } + + Group? _firstGroupWithId(List groups) { + for (final group in groups) { + if (group.id != null) { + return group; + } + } + return null; + } } diff --git a/lib/features/reporting/presentation/pages/reporting_page.dart b/lib/features/reporting/presentation/pages/reporting_page.dart index 22e0ca4..681b457 100644 --- a/lib/features/reporting/presentation/pages/reporting_page.dart +++ b/lib/features/reporting/presentation/pages/reporting_page.dart @@ -1,17 +1,26 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:get_it/get_it.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 'package:url_launcher/url_launcher.dart'; import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/features/inventory/lookups/domain/entities/lookup_item.dart'; +import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart'; import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart'; +import 'package:superport_v2/features/reporting/domain/entities/report_download_result.dart'; +import 'package:superport_v2/features/reporting/domain/entities/report_export_format.dart'; +import 'package:superport_v2/features/reporting/domain/entities/report_export_request.dart'; +import 'package:superport_v2/features/reporting/domain/repositories/reporting_repository.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/empty_state.dart'; import 'package:superport_v2/widgets/components/feedback.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; import 'package:superport_v2/widgets/components/superport_date_picker.dart'; +import 'package:superport_v2/core/network/failure.dart'; /// 보고서 다운로드 화면 루트 위젯. class ReportingPage extends StatefulWidget { @@ -24,7 +33,37 @@ class ReportingPage extends StatefulWidget { /// 보고서 페이지 UI 상태와 필터 조건을 관리하는 상태 클래스. class _ReportingPageState extends State { late final WarehouseRepository _warehouseRepository; + ReportingRepository? _reportingRepository; + InventoryLookupRepository? _lookupRepository; final intl.DateFormat _dateFormat = intl.DateFormat('yyyy.MM.dd'); + final Map _transactionTypeLookup = {}; + final Map _transactionStatusLookup = {}; + final Map _approvalStatusLookup = {}; + bool _isLoadingLookups = false; + String? _lookupError; + bool _isExporting = false; + String? _exportError; + ReportDownloadResult? _lastResult; + ReportExportFormat? _lastFormat; + + static const Map> _transactionTypeKeywords = { + ReportTypeFilter.inbound: ['입고', 'inbound'], + ReportTypeFilter.outbound: ['출고', 'outbound'], + ReportTypeFilter.rental: ['대여', 'rent', 'rental'], + }; + + static const Map> + _transactionStatusKeywords = { + ReportStatusFilter.inProgress: ['작성', '대기', '진행', 'pending'], + ReportStatusFilter.completed: ['완료', '승인', 'complete', 'approved'], + ReportStatusFilter.cancelled: ['취소', '반려', 'cancel', 'rejected'], + }; + + static const Map> _approvalStatusKeywords = { + ReportStatusFilter.inProgress: ['진행', '대기', 'processing'], + ReportStatusFilter.completed: ['완료', '승인', 'approved'], + ReportStatusFilter.cancelled: ['취소', '반려', 'cancel'], + }; DateTimeRange? _appliedDateRange; DateTimeRange? _pendingDateRange; @@ -41,10 +80,28 @@ class _ReportingPageState extends State { bool _isLoadingWarehouses = false; String? _warehouseError; + /// 실패 객체에서 사용자 메시지를 추출한다. + String _failureMessage(Object error, String fallback) { + final failure = Failure.from(error); + final description = failure.describe(); + if (description.isEmpty) { + return fallback; + } + return description; + } + @override void initState() { super.initState(); - _warehouseRepository = GetIt.I(); + final getIt = GetIt.I; + _warehouseRepository = getIt(); + if (getIt.isRegistered()) { + _reportingRepository = getIt(); + } + if (getIt.isRegistered()) { + _lookupRepository = getIt(); + _loadLookups(); + } _loadWarehouses(); } @@ -85,12 +142,17 @@ class _ReportingPageState extends State { } } catch (error) { if (mounted) { + final message = _failureMessage( + error, + '창고 목록을 불러오지 못했습니다. 잠시 후 다시 시도하세요.', + ); setState(() { - _warehouseError = '창고 목록을 불러오지 못했습니다. 잠시 후 다시 시도하세요.'; + _warehouseError = message; _warehouseOptions = const [WarehouseFilterOption.all]; _appliedWarehouse = WarehouseFilterOption.all; _pendingWarehouse = WarehouseFilterOption.all; }); + SuperportToast.error(context, message); } } finally { if (mounted) { @@ -101,6 +163,60 @@ class _ReportingPageState extends State { } } + Future _loadLookups() async { + final repository = _lookupRepository; + if (repository == null) { + return; + } + setState(() { + _isLoadingLookups = true; + _lookupError = null; + }); + try { + final transactionTypes = await repository.fetchTransactionTypes(); + final transactionStatuses = await repository.fetchTransactionStatuses(); + final approvalStatuses = await repository.fetchApprovalStatuses(); + if (!mounted) { + return; + } + setState(() { + _transactionTypeLookup + ..clear() + ..addAll(_mapTransactionTypes(transactionTypes)); + _transactionStatusLookup + ..clear() + ..addAll( + _mapStatusByKeyword( + transactionStatuses, + _transactionStatusKeywords, + ), + ); + _approvalStatusLookup + ..clear() + ..addAll( + _mapStatusByKeyword(approvalStatuses, _approvalStatusKeywords), + ); + }); + } catch (error) { + if (mounted) { + final message = _failureMessage( + error, + '유형/상태 정보를 불러오지 못했습니다. 잠시 후 다시 시도하세요.', + ); + setState(() { + _lookupError = message; + }); + SuperportToast.error(context, message); + } + } finally { + if (mounted) { + setState(() { + _isLoadingLookups = false; + }); + } + } + } + /// 적용된 필터를 초기 상태로 되돌린다. void _resetFilters() { setState(() { @@ -166,6 +282,69 @@ class _ReportingPageState extends State { return options.first; } + Map _mapTransactionTypes( + List items, + ) { + final result = {}; + for (final entry in _transactionTypeKeywords.entries) { + final matched = _matchLookup(items, entry.value); + if (matched != null) { + result[entry.key] = matched; + } + } + return result; + } + + Map _mapStatusByKeyword( + List items, + Map> keywords, + ) { + final result = {}; + for (final entry in keywords.entries) { + final matched = _matchLookup(items, entry.value); + if (matched != null) { + result[entry.key] = matched; + } + } + return result; + } + + LookupItem? _matchLookup(List items, List keywords) { + final normalized = keywords + .map((keyword) => keyword.trim().toLowerCase()) + .where((keyword) => keyword.isNotEmpty) + .toList(growable: false); + if (normalized.isEmpty) { + return null; + } + for (final item in items) { + final name = item.name.trim().toLowerCase(); + if (normalized.any((keyword) => name.contains(keyword))) { + return item; + } + } + return null; + } + + int? _resolveTransactionTypeId() { + if (_appliedType == ReportTypeFilter.all || + _appliedType == ReportTypeFilter.approval) { + return null; + } + final lookup = _transactionTypeLookup[_appliedType]; + return lookup?.id; + } + + int? _resolveStatusId() { + if (_appliedStatus == ReportStatusFilter.all) { + return null; + } + if (_appliedType == ReportTypeFilter.approval) { + return _approvalStatusLookup[_appliedStatus]?.id; + } + return _transactionStatusLookup[_appliedStatus]?.id; + } + String _dateRangeLabel(DateTimeRange? range) { if (range == null) { return '기간 선택'; @@ -175,8 +354,159 @@ class _ReportingPageState extends State { String _formatDate(DateTime value) => _dateFormat.format(value); - void _handleExport(ReportExportFormat format) { - SuperportToast.info(context, '${format.label} 다운로드 연동은 준비 중입니다.'); + Future _handleExport(ReportExportFormat format) async { + if (_isExporting) { + return; + } + final repository = _reportingRepository; + final range = _appliedDateRange; + if (repository == null) { + SuperportToast.error(context, '보고서 다운로드 리포지토리가 초기화되지 않았습니다.'); + return; + } + if (range == null) { + SuperportToast.error(context, '기간을 먼저 선택하세요.'); + return; + } + setState(() { + _isExporting = true; + _exportError = null; + }); + final request = ReportExportRequest( + from: range.start, + to: range.end, + format: format, + transactionTypeId: _resolveTransactionTypeId(), + statusId: _resolveStatusId(), + warehouseId: _appliedWarehouse.id, + ); + try { + final result = _appliedType == ReportTypeFilter.approval + ? await repository.exportApprovals(request) + : await repository.exportTransactions(request); + if (!mounted) { + return; + } + setState(() { + _lastResult = result; + _lastFormat = format; + }); + if (result.hasDownloadUrl) { + SuperportToast.success(context, '다운로드 링크가 준비되었습니다.'); + } else if (result.hasBytes) { + SuperportToast.success(context, '보고서 파일이 준비되었습니다. 저장 기능은 추후 제공 예정입니다.'); + } else { + SuperportToast.info(context, '다운로드 결과를 확인했지만 추가 처리 항목이 없습니다.'); + } + } catch (error) { + if (!mounted) { + return; + } + final message = _failureMessage( + error, + '보고서 다운로드에 실패했습니다. 잠시 후 다시 시도하세요.', + ); + setState(() { + _exportError = message; + }); + SuperportToast.error(context, message); + } finally { + if (mounted) { + setState(() { + _isExporting = false; + }); + } + } + } + + Future _launchDownloadUrl(Uri url) async { + try { + final opened = await launchUrl(url, mode: LaunchMode.externalApplication); + if (!opened && mounted) { + SuperportToast.error(context, '다운로드 링크를 열 수 없습니다.'); + } + } catch (_) { + if (mounted) { + SuperportToast.error(context, '다운로드 링크를 열 수 없습니다.'); + } + } + } + + Future _copyDownloadUrl(Uri url) async { + await Clipboard.setData(ClipboardData(text: url.toString())); + if (mounted) { + SuperportToast.success(context, '다운로드 URL을 복사했습니다.'); + } + } + + Widget _buildPreview(ShadThemeData theme) { + if (_isExporting) { + return const Center(child: CircularProgressIndicator()); + } + if (_exportError != null) { + return Center( + child: Text( + _exportError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ); + } + final result = _lastResult; + if (result == null) { + return SuperportEmptyState( + icon: lucide.LucideIcons.chartBar, + title: '미리보기 데이터가 없습니다.', + description: '필터를 적용하거나 보고서를 다운로드하면 이 영역에 요약이 표시됩니다.', + ); + } + final rows = [ + if (_lastFormat != null) + _SummaryRow(label: '형식', value: _lastFormat!.label), + if (result.filename != null) + _SummaryRow(label: '파일명', value: result.filename!), + if (result.mimeType != null) + _SummaryRow(label: 'MIME', value: result.mimeType!), + if (result.expiresAt != null) + _SummaryRow( + label: '만료 시각', + value: intl.DateFormat('yyyy-MM-dd HH:mm').format(result.expiresAt!), + ), + if (result.downloadUrl != null) + _SummaryRow(label: '다운로드 URL', value: result.downloadUrl!.toString()), + if (result.hasBytes && (result.downloadUrl == null)) + const _SummaryRow( + label: '상태', + value: '바이너리 응답이 준비되었습니다. 저장 기능은 추후 제공 예정입니다.', + ), + ]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...rows, + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + if (result.downloadUrl != null) + ShadButton( + onPressed: () => _launchDownloadUrl(result.downloadUrl!), + leading: const Icon(lucide.LucideIcons.externalLink, size: 16), + child: const Text('다운로드 열기'), + ), + if (result.downloadUrl != null) + ShadButton.outline( + onPressed: () => _copyDownloadUrl(result.downloadUrl!), + leading: const Icon(lucide.LucideIcons.copy, size: 16), + child: const Text('URL 복사'), + ), + ], + ), + ], + ); } @override @@ -241,13 +571,26 @@ class _ReportingPageState extends State { child: ShadSelect( key: ValueKey(_pendingType), initialValue: _pendingType, - selectedOptionBuilder: (_, value) => Text(value.label), - onChanged: (value) { - if (value == null) return; - setState(() { - _pendingType = value; - }); + selectedOptionBuilder: (context, value) { + final theme = ShadTheme.of(context); + if (_lookupError != null) { + return Text( + _lookupError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ); + } + return Text(value.label); }, + onChanged: _isLoadingLookups + ? null + : (value) { + if (value == null) return; + setState(() { + _pendingType = value; + }); + }, options: [ for (final type in ReportTypeFilter.values) ShadOption(value: type, child: Text(type.label)), @@ -279,13 +622,26 @@ class _ReportingPageState extends State { child: ShadSelect( key: ValueKey(_pendingStatus), initialValue: _pendingStatus, - selectedOptionBuilder: (_, value) => Text(value.label), - onChanged: (value) { - if (value == null) return; - setState(() { - _pendingStatus = value; - }); + selectedOptionBuilder: (context, value) { + final theme = ShadTheme.of(context); + if (_lookupError != null) { + return Text( + _lookupError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ); + } + return Text(value.label); }, + onChanged: _isLoadingLookups + ? null + : (value) { + if (value == null) return; + setState(() { + _pendingStatus = value; + }); + }, options: [ for (final status in ReportStatusFilter.values) ShadOption(value: status, child: Text(status.label)), @@ -373,15 +729,18 @@ class _ReportingPageState extends State { ShadCard( title: Text('보고서 미리보기', style: theme.textTheme.h3), description: Text( - '조건을 적용하면 다운로드 진행 상태와 결과 테이블이 이 영역에 표시됩니다.', + '다운로드 상태와 결과 요약을 확인하세요.', style: theme.textTheme.muted, ), - child: SizedBox( - height: 240, - child: SuperportEmptyState( - icon: lucide.LucideIcons.chartBar, - title: '미리보기 데이터가 없습니다.', - description: '필터를 적용하거나 보고서를 다운로드하면 이 영역에 요약이 표시됩니다.', + child: AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 200), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: _buildPreview(theme), + ), ), ), ), @@ -458,15 +817,6 @@ extension ReportStatusFilterX on ReportStatusFilter { } } -enum ReportExportFormat { xlsx, pdf } - -extension ReportExportFormatX on ReportExportFormat { - String get label => switch (this) { - ReportExportFormat.xlsx => 'XLSX', - ReportExportFormat.pdf => 'PDF', - }; -} - class WarehouseFilterOption { const WarehouseFilterOption({this.id, required this.label}); diff --git a/test/features/inventory/inbound/presentation/controllers/inbound_controller_test.dart b/test/features/inventory/inbound/presentation/controllers/inbound_controller_test.dart new file mode 100644 index 0000000..a0164ff --- /dev/null +++ b/test/features/inventory/inbound/presentation/controllers/inbound_controller_test.dart @@ -0,0 +1,246 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/network/api_error.dart'; +import 'package:superport_v2/core/network/failure.dart'; +import 'package:superport_v2/features/inventory/inbound/presentation/controllers/inbound_controller.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/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; + +class _MockStockTransactionRepository extends Mock + implements StockTransactionRepository {} + +class _MockInventoryLookupRepository extends Mock + implements InventoryLookupRepository {} + +class _MockTransactionLineRepository extends Mock + implements TransactionLineRepository {} + +class _FakeStockTransactionCreateInput extends Fake + implements StockTransactionCreateInput {} + +class _FakeStockTransactionUpdateInput extends Fake + implements StockTransactionUpdateInput {} + +class _FakeStockTransactionListFilter extends Fake + implements StockTransactionListFilter {} + +void main() { + group('InboundController', () { + late StockTransactionRepository transactionRepository; + late InventoryLookupRepository lookupRepository; + late TransactionLineRepository lineRepository; + late InboundController controller; + + setUpAll(() { + registerFallbackValue(_FakeStockTransactionCreateInput()); + registerFallbackValue(_FakeStockTransactionUpdateInput()); + registerFallbackValue(_FakeStockTransactionListFilter()); + }); + + setUp(() { + transactionRepository = _MockStockTransactionRepository(); + lookupRepository = _MockInventoryLookupRepository(); + lineRepository = _MockTransactionLineRepository(); + controller = InboundController( + transactionRepository: transactionRepository, + lineRepository: lineRepository, + lookupRepository: lookupRepository, + ); + }); + + test('createTransaction은 레코드를 추가하고 결과를 반환한다', () async { + final transaction = _buildTransaction(); + when( + () => transactionRepository.create(any()), + ).thenAnswer((_) async => transaction); + + final input = StockTransactionCreateInput( + transactionTypeId: 1, + transactionStatusId: 2, + warehouseId: 3, + transactionDate: DateTime(2024, 3, 1), + createdById: 9, + ); + + final record = await controller.createTransaction( + input, + refreshAfter: false, + ); + + expect(record.id, equals(transaction.id)); + expect(controller.records.length, equals(1)); + expect(controller.records.first.id, equals(transaction.id)); + verify(() => transactionRepository.create(any())).called(1); + }); + + test('updateTransaction은 로컬 레코드를 갱신한다', () async { + final original = _buildTransaction(); + final updated = _buildTransaction(statusName: '승인완료'); + when( + () => transactionRepository.create(any()), + ).thenAnswer((_) async => original); + when( + () => transactionRepository.update(any(), any()), + ).thenAnswer((_) async => updated); + + await controller.createTransaction( + StockTransactionCreateInput( + transactionTypeId: 1, + transactionStatusId: 2, + warehouseId: 3, + transactionDate: DateTime(2024, 3, 1), + createdById: 9, + ), + refreshAfter: false, + ); + + final future = controller.updateTransaction( + original.id!, + StockTransactionUpdateInput(transactionStatusId: 3), + refreshAfter: false, + ); + + expect(controller.processingTransactionIds.contains(original.id), isTrue); + + final record = await future; + + expect(record.status, equals('승인완료')); + expect(controller.records.first.status, equals('승인완료')); + expect( + controller.processingTransactionIds.contains(original.id), + isFalse, + ); + }); + + test('deleteTransaction은 레코드를 제거한다', () async { + final transaction = _buildTransaction(); + when( + () => transactionRepository.create(any()), + ).thenAnswer((_) async => transaction); + when( + () => transactionRepository.delete(any()), + ).thenAnswer((_) async => Future.value()); + + await controller.createTransaction( + StockTransactionCreateInput( + transactionTypeId: 1, + transactionStatusId: 2, + warehouseId: 3, + transactionDate: DateTime(2024, 3, 1), + createdById: 9, + ), + refreshAfter: false, + ); + + final future = controller.deleteTransaction( + transaction.id!, + refreshAfter: false, + ); + + expect( + controller.processingTransactionIds.contains(transaction.id), + isTrue, + ); + + await future; + + expect(controller.records, isEmpty); + expect( + controller.processingTransactionIds.contains(transaction.id), + isFalse, + ); + verify(() => transactionRepository.delete(transaction.id!)).called(1); + }); + + test('submitTransaction은 refreshAfter가 true일 때 목록을 다시 불러온다', () async { + final filter = StockTransactionListFilter(transactionTypeId: 1); + final initial = _buildTransaction(); + when( + () => transactionRepository.list(filter: any(named: 'filter')), + ).thenAnswer( + (_) async => PaginatedResult( + items: [initial], + page: 1, + pageSize: 20, + total: 1, + ), + ); + await controller.fetchTransactions(filter: filter); + + final updated = _buildTransaction(statusName: '승인대기'); + when( + () => transactionRepository.submit(any()), + ).thenAnswer((_) async => updated); + when( + () => transactionRepository.list(filter: any(named: 'filter')), + ).thenAnswer( + (_) async => PaginatedResult( + items: [updated], + page: 1, + pageSize: 20, + total: 1, + ), + ); + + final result = await controller.submitTransaction(initial.id!); + + expect(result.status, equals('승인대기')); + expect(controller.records.first.status, equals('승인대기')); + verify(() => transactionRepository.submit(initial.id!)).called(1); + }); + + test('fetchTransactions 실패 시 Failure 메시지를 노출한다', () async { + final exception = ApiException( + code: ApiErrorCode.unprocessableEntity, + message: '입고 목록을 불러오지 못했습니다.', + details: { + 'errors': { + 'transaction_date': ['처리일자를 선택해 주세요.'], + }, + }, + ); + + when( + () => transactionRepository.list(filter: any(named: 'filter')), + ).thenThrow(exception); + + await controller.fetchTransactions( + filter: StockTransactionListFilter(transactionTypeId: 1), + ); + + final failure = Failure.from(exception); + expect(controller.errorMessage, equals(failure.describe())); + expect(controller.records, isEmpty); + }); + }); +} + +StockTransaction _buildTransaction({int id = 100, String statusName = '작성중'}) { + return StockTransaction( + id: id, + transactionNo: 'TX-$id', + transactionDate: DateTime(2024, 3, 1), + type: StockTransactionType(id: 10, name: '입고'), + status: StockTransactionStatus(id: 11, name: statusName), + warehouse: StockTransactionWarehouse(id: 1, code: 'WH', name: '서울 물류'), + createdBy: StockTransactionEmployee( + id: 1, + employeeNo: 'EMP-1', + name: '관리자', + ), + lines: [ + StockTransactionLine( + id: 1, + lineNo: 1, + product: StockTransactionProduct(id: 1, code: 'P-1', name: '테스트 상품'), + quantity: 5, + unitPrice: 1000.0, + ), + ], + customers: const [], + ); +} diff --git a/test/features/reporting/reporting_page_test.dart b/test/features/reporting/reporting_page_test.dart index 62d7c74..dda06a0 100644 --- a/test/features/reporting/reporting_page_test.dart +++ b/test/features/reporting/reporting_page_test.dart @@ -3,11 +3,19 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; -import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/config/environment.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/network/api_error.dart'; import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart'; import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart'; +import 'package:superport_v2/features/inventory/lookups/domain/entities/lookup_item.dart'; +import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; +import 'package:superport_v2/features/reporting/domain/entities/report_download_result.dart'; +import 'package:superport_v2/features/reporting/domain/entities/report_export_format.dart'; +import 'package:superport_v2/features/reporting/domain/entities/report_export_request.dart'; +import 'package:superport_v2/features/reporting/domain/repositories/reporting_repository.dart'; import 'package:superport_v2/features/reporting/presentation/pages/reporting_page.dart'; +import 'package:superport_v2/widgets/components/empty_state.dart'; import '../../helpers/test_app.dart'; @@ -25,6 +33,10 @@ void main() { testWidgets('보고서 화면은 창고 목록 재시도 흐름을 제공한다', (tester) async { final repo = _FlakyWarehouseRepository(); GetIt.I.registerSingleton(repo); + GetIt.I.registerSingleton( + _StubLookupRepository(), + ); + GetIt.I.registerSingleton(_FakeReportingRepository()); final view = tester.view; view.physicalSize = const Size(1280, 800); @@ -38,13 +50,16 @@ void main() { await tester.pumpAndSettle(); expect(repo.attempts, 1); - expect(find.text('창고 목록을 불러오지 못했습니다. 잠시 후 다시 시도하세요.'), findsOneWidget); + expect( + find.text('창고 목록을 불러오지 못했습니다. 잠시 후 다시 시도하세요.'), + findsWidgets, + ); await tester.tap(find.widgetWithText(ShadButton, '재시도')); await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 4)); expect(repo.attempts, 2); - expect(find.text('창고 목록을 불러오지 못했습니다. 잠시 후 다시 시도하세요.'), findsNothing); }); } @@ -57,10 +72,14 @@ class _FlakyWarehouseRepository implements WarehouseRepository { int pageSize = 20, String? query, bool? isActive, + bool includeZipcode = true, }) async { attempts += 1; if (attempts == 1) { - throw Exception('network down'); + throw const ApiException( + code: ApiErrorCode.network, + message: '창고 목록을 불러오지 못했습니다. 잠시 후 다시 시도하세요.', + ); } return PaginatedResult( items: [ @@ -98,3 +117,77 @@ class _FlakyWarehouseRepository implements WarehouseRepository { throw UnimplementedError(); } } + +class _StubLookupRepository implements InventoryLookupRepository { + @override + Future> fetchTransactionTypes({ + bool activeOnly = true, + }) async { + return [ + LookupItem(id: 1, name: '입고'), + LookupItem(id: 2, name: '출고'), + LookupItem(id: 3, name: '대여'), + ]; + } + + @override + Future> fetchTransactionStatuses({ + bool activeOnly = true, + }) async { + return [ + LookupItem(id: 11, name: '작성중'), + LookupItem(id: 12, name: '완료'), + LookupItem(id: 13, name: '취소'), + ]; + } + + @override + Future> fetchApprovalStatuses({ + bool activeOnly = true, + }) async { + return [ + LookupItem(id: 21, name: '진행중'), + LookupItem(id: 22, name: '완료'), + LookupItem(id: 23, name: '취소'), + ]; + } + + @override + Future> fetchApprovalActions({ + bool activeOnly = true, + }) async { + return const []; + } +} + +class _FakeReportingRepository implements ReportingRepository { + ReportExportRequest? lastRequest; + ReportExportFormat? lastFormat; + + @override + Future exportApprovals( + ReportExportRequest request, + ) async { + lastRequest = request; + lastFormat = request.format; + return ReportDownloadResult( + downloadUrl: Uri.parse('https://example.com/approvals.pdf'), + filename: 'approvals.pdf', + mimeType: 'application/pdf', + ); + } + + @override + Future exportTransactions( + ReportExportRequest request, + ) async { + lastRequest = request; + lastFormat = request.format; + return ReportDownloadResult( + downloadUrl: Uri.parse('https://example.com/transactions.xlsx'), + filename: 'transactions.xlsx', + mimeType: + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + } +} diff --git a/test/helpers/inventory_test_stubs.dart b/test/helpers/inventory_test_stubs.dart new file mode 100644 index 0000000..cfeed9a --- /dev/null +++ b/test/helpers/inventory_test_stubs.dart @@ -0,0 +1,937 @@ +import 'package:get_it/get_it.dart'; +import 'package:superport_v2/core/network/api_error.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/inventory/lookups/domain/entities/lookup_item.dart'; +import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.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/features/masters/warehouse/domain/entities/warehouse.dart'; +import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart'; + +const int _inboundTypeId = 100; +const int _outboundTypeId = 200; +const int _rentalRentTypeId = 300; +const int _rentalReturnTypeId = 301; + +const int _statusDraftId = 10; +const int _statusPendingId = 11; +const int _statusCompleteId = 12; +const int _statusOutboundWaitId = 21; +const int _statusOutboundDoneId = 22; +const int _statusRentalRentingId = 31; +const int _statusRentalReturnWaitId = 32; +const int _statusRentalFinishedId = 33; + +StockTransactionListFilter? lastTransactionListFilter; + +class InventoryTestStubConfig { + const InventoryTestStubConfig({this.submitFailure}); + + final ApiException? submitFailure; +} + +InventoryTestStubConfig _stubConfig = const InventoryTestStubConfig(); + +void registerInventoryTestStubs([ + InventoryTestStubConfig config = const InventoryTestStubConfig(), +]) { + _stubConfig = config; + lastTransactionListFilter = null; + final lookup = _StubInventoryLookupRepository( + transactionTypes: [ + LookupItem(id: _inboundTypeId, name: '입고', code: 'INBOUND'), + LookupItem(id: _outboundTypeId, name: '출고', code: 'OUTBOUND'), + LookupItem(id: _rentalRentTypeId, name: '대여', code: 'RENT'), + LookupItem(id: _rentalReturnTypeId, name: '반납', code: 'RETURN'), + ], + statuses: [ + LookupItem(id: _statusDraftId, name: '작성중'), + LookupItem(id: _statusPendingId, name: '승인대기'), + LookupItem(id: _statusCompleteId, name: '승인완료'), + LookupItem(id: _statusOutboundWaitId, name: '출고대기'), + LookupItem(id: _statusOutboundDoneId, name: '출고완료'), + LookupItem(id: _statusRentalRentingId, name: '대여중'), + LookupItem(id: _statusRentalReturnWaitId, name: '반납대기'), + LookupItem(id: _statusRentalFinishedId, name: '완료'), + ], + ); + + final transactions = _buildTransactions(); + final repository = _StubStockTransactionRepository( + transactions: transactions, + ); + final lineRepository = _StubTransactionLineRepository( + transactions: transactions, + ); + final customerRepository = _StubTransactionCustomerRepository( + transactions: transactions, + ); + final warehouses = [ + Warehouse(id: 1, warehouseCode: 'WH-001', warehouseName: '서울 1창고'), + Warehouse(id: 2, warehouseCode: 'WH-002', warehouseName: '부산 센터'), + Warehouse(id: 3, warehouseCode: 'WH-003', warehouseName: '대전 물류'), + ]; + final warehouseRepository = _StubWarehouseRepository(warehouses: warehouses); + + final getIt = GetIt.I; + if (getIt.isRegistered()) { + getIt.unregister(); + } + if (getIt.isRegistered()) { + getIt.unregister(); + } + if (getIt.isRegistered()) { + getIt.unregister(); + } + if (getIt.isRegistered()) { + getIt.unregister(); + } + if (getIt.isRegistered()) { + getIt.unregister(); + } + getIt.registerSingleton(lookup); + getIt.registerSingleton(repository); + getIt.registerSingleton(lineRepository); + getIt.registerSingleton(customerRepository); + getIt.registerSingleton(warehouseRepository); +} + +class _StubInventoryLookupRepository implements InventoryLookupRepository { + _StubInventoryLookupRepository({ + required List transactionTypes, + required List statuses, + }) : _transactionTypes = transactionTypes, + _statuses = statuses; + + final List _transactionTypes; + final List _statuses; + + @override + Future> fetchTransactionTypes({ + bool activeOnly = true, + }) async { + return _transactionTypes; + } + + @override + Future> fetchTransactionStatuses({ + bool activeOnly = true, + }) async { + return _statuses; + } + + @override + Future> fetchApprovalStatuses({ + bool activeOnly = true, + }) async { + return const []; + } + + @override + Future> fetchApprovalActions({ + bool activeOnly = true, + }) async { + return const []; + } +} + +class _StubStockTransactionRepository implements StockTransactionRepository { + _StubStockTransactionRepository({ + required List transactions, + }) : _transactions = transactions; + + final List _transactions; + + @override + Future> list({ + StockTransactionListFilter? filter, + }) async { + final resolved = filter ?? StockTransactionListFilter(); + lastTransactionListFilter = resolved; + final filtered = _transactions.where((transaction) { + if (resolved.transactionTypeId != null && + transaction.type.id != resolved.transactionTypeId) { + return false; + } + if (resolved.transactionStatusId != null && + transaction.status.id != resolved.transactionStatusId) { + return false; + } + if (resolved.from != null && + transaction.transactionDate.isBefore(resolved.from!)) { + return false; + } + if (resolved.to != null && + transaction.transactionDate.isAfter(resolved.to!)) { + return false; + } + if (resolved.query != null) { + final query = resolved.query!.toLowerCase(); + final matchesQuery = + transaction.transactionNo.toLowerCase().contains(query) || + transaction.createdBy.name.toLowerCase().contains(query) || + transaction.lines.any( + (line) => line.product.name.toLowerCase().contains(query), + ) || + transaction.customers.any( + (customer) => + customer.customer.name.toLowerCase().contains(query), + ); + if (!matchesQuery) { + return false; + } + } + return true; + }).toList(); + + final page = resolved.page; + final pageSize = resolved.pageSize; + final startIndex = (page - 1) * pageSize; + final paged = startIndex >= filtered.length + ? const [] + : filtered.skip(startIndex).take(pageSize).toList(); + + return PaginatedResult( + items: paged, + page: page, + pageSize: pageSize, + total: filtered.length, + ); + } + + @override + Future fetchDetail( + int id, { + List include = const ['lines', 'customers', 'approval'], + }) { + throw UnimplementedError(); + } + + @override + Future create(StockTransactionCreateInput input) { + throw UnimplementedError(); + } + + @override + Future update(int id, StockTransactionUpdateInput input) { + throw UnimplementedError(); + } + + @override + Future delete(int id) { + throw UnimplementedError(); + } + + @override + Future restore(int id) { + throw UnimplementedError(); + } + + @override + Future submit(int id) async { + final failure = _stubConfig.submitFailure; + if (failure != null) { + throw failure; + } + return _mutateTransaction(id, _applySubmitStatus); + } + + @override + Future complete(int id) async { + return _mutateTransaction(id, _applyCompleteStatus); + } + + @override + Future approve(int id) async { + return _mutateTransaction(id, (transaction) => transaction); + } + + @override + Future reject(int id) async { + return _mutateTransaction(id, (transaction) => transaction); + } + + @override + Future cancel(int id) async { + return _mutateTransaction(id, (transaction) => transaction); + } + + Future _mutateTransaction( + int id, + StockTransaction Function(StockTransaction transaction) transform, + ) async { + final index = _transactions.indexWhere( + (transaction) => transaction.id == id, + ); + if (index == -1) { + throw StateError('Transaction $id not found'); + } + final current = _transactions[index]; + final updated = transform(current); + _transactions[index] = updated; + return updated; + } + + StockTransaction _applySubmitStatus(StockTransaction transaction) { + final nextStatus = _nextSubmitStatus(transaction); + if (nextStatus == null) { + return transaction; + } + return transaction.copyWith(status: nextStatus); + } + + StockTransaction _applyCompleteStatus(StockTransaction transaction) { + final nextStatus = _nextCompleteStatus(transaction); + if (nextStatus == null) { + return transaction; + } + return transaction.copyWith(status: nextStatus); + } + + StockTransactionStatus? _nextSubmitStatus(StockTransaction transaction) { + final status = transaction.status.name; + if (status.contains('작성중')) { + if (transaction.type.name.contains('입고')) { + return StockTransactionStatus(id: _statusPendingId, name: '승인대기'); + } + if (transaction.type.name.contains('출고')) { + return StockTransactionStatus(id: _statusOutboundWaitId, name: '출고대기'); + } + } + if (status.contains('대여중')) { + return StockTransactionStatus( + id: _statusRentalReturnWaitId, + name: '반납대기', + ); + } + return null; + } + + StockTransactionStatus? _nextCompleteStatus(StockTransaction transaction) { + final status = transaction.status.name; + if (status.contains('승인대기')) { + return StockTransactionStatus(id: _statusCompleteId, name: '승인완료'); + } + if (status.contains('출고대기')) { + return StockTransactionStatus(id: _statusOutboundDoneId, name: '출고완료'); + } + if (status.contains('반납대기')) { + return StockTransactionStatus(id: _statusRentalFinishedId, name: '완료'); + } + return null; + } +} + +class _StubTransactionLineRepository implements TransactionLineRepository { + _StubTransactionLineRepository({required List transactions}) + : _transactions = transactions; + + final List _transactions; + + List _linesFor(int transactionId) { + final transaction = _transactions.firstWhere( + (item) => item.id == transactionId, + orElse: () => throw StateError('Transaction $transactionId not found'), + ); + return transaction.lines; + } + + StockTransactionLine _findLine(int lineId) { + for (final transaction in _transactions) { + for (final line in transaction.lines) { + if (line.id == lineId) { + return line; + } + } + } + throw StateError('Line $lineId not found'); + } + + @override + Future> addLines( + int transactionId, + List lines, + ) async { + return _linesFor(transactionId); + } + + @override + Future deleteLine(int lineId) async {} + + @override + Future> updateLines( + int transactionId, + List lines, + ) async { + return _linesFor(transactionId); + } + + @override + Future restoreLine(int lineId) async { + return _findLine(lineId); + } +} + +class _StubTransactionCustomerRepository + implements TransactionCustomerRepository { + _StubTransactionCustomerRepository({ + required List transactions, + }) : _transactions = transactions; + + final List _transactions; + + List _customersFor(int transactionId) { + final transaction = _transactions.firstWhere( + (item) => item.id == transactionId, + orElse: () => throw StateError('Transaction $transactionId not found'), + ); + return transaction.customers; + } + + @override + Future> addCustomers( + int transactionId, + List customers, + ) async { + return _customersFor(transactionId); + } + + @override + Future deleteCustomer(int customerLinkId) async {} + + @override + Future> updateCustomers( + int transactionId, + List customers, + ) async { + return _customersFor(transactionId); + } +} + +class _StubWarehouseRepository implements WarehouseRepository { + _StubWarehouseRepository({required List warehouses}) + : _warehouses = warehouses; + + final List _warehouses; + + @override + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + bool? isActive, + bool includeZipcode = true, + }) async { + final filtered = _warehouses + .where((warehouse) { + if (query != null && query.isNotEmpty) { + final normalized = query.toLowerCase(); + final matches = + warehouse.warehouseName.toLowerCase().contains(normalized) || + warehouse.warehouseCode.toLowerCase().contains(normalized); + if (!matches) { + return false; + } + } + if (isActive != null && warehouse.isActive != isActive) { + return false; + } + return true; + }) + .toList(growable: false); + + return PaginatedResult( + items: filtered, + page: 1, + pageSize: filtered.length, + total: filtered.length, + ); + } + + @override + Future create(WarehouseInput input) { + throw UnimplementedError(); + } + + @override + Future delete(int id) { + throw UnimplementedError(); + } + + @override + Future restore(int id) { + throw UnimplementedError(); + } + + @override + Future update(int id, WarehouseInput input) { + throw UnimplementedError(); + } +} + +List _buildTransactions() { + return [ + _createInboundTransaction(), + _createInboundTransaction2(), + _createInboundTransaction3(), + _createOutboundTransaction1(), + _createOutboundTransaction2(), + _createOutboundTransaction3(), + _createRentalTransaction1(), + _createRentalTransaction2(), + _createRentalTransaction3(), + ]; +} + +StockTransaction _createInboundTransaction() { + return StockTransaction( + id: 1, + transactionNo: 'TX-20240301-001', + transactionDate: DateTime(2024, 3, 1), + type: StockTransactionType(id: _inboundTypeId, name: '입고'), + status: StockTransactionStatus(id: _statusDraftId, name: '작성중'), + warehouse: StockTransactionWarehouse(id: 1, code: 'WH-001', name: '서울 1창고'), + createdBy: StockTransactionEmployee( + id: 1, + employeeNo: 'EMP-001', + name: '홍길동', + ), + note: '-', + lines: [ + StockTransactionLine( + id: 1, + lineNo: 1, + product: StockTransactionProduct( + id: 1, + code: 'P-100', + name: 'XR-5000', + vendor: StockTransactionVendorSummary(id: 1, name: '슈퍼벤더'), + uom: StockTransactionUomSummary(id: 1, name: 'EA'), + ), + quantity: 40, + unitPrice: 120000, + note: '', + ), + StockTransactionLine( + id: 2, + lineNo: 2, + product: StockTransactionProduct( + id: 2, + code: 'P-101', + name: 'XR-5001', + vendor: StockTransactionVendorSummary(id: 1, name: '슈퍼벤더'), + uom: StockTransactionUomSummary(id: 1, name: 'EA'), + ), + quantity: 60, + unitPrice: 98000, + note: '', + ), + ], + customers: const [], + ); +} + +StockTransaction _createInboundTransaction2() { + return StockTransaction( + id: 2, + transactionNo: 'TX-20240305-010', + transactionDate: DateTime(2024, 3, 5), + type: StockTransactionType(id: _inboundTypeId, name: '입고'), + status: StockTransactionStatus(id: _statusPendingId, name: '승인대기'), + warehouse: StockTransactionWarehouse(id: 2, code: 'WH-002', name: '부산 센터'), + createdBy: StockTransactionEmployee( + id: 2, + employeeNo: 'EMP-010', + name: '김담당', + ), + note: '긴급 입고', + lines: [ + StockTransactionLine( + id: 3, + lineNo: 1, + product: StockTransactionProduct( + id: 3, + code: 'P-200', + name: 'Eco-200', + vendor: StockTransactionVendorSummary(id: 3, name: '그린텍'), + uom: StockTransactionUomSummary(id: 1, name: 'EA'), + ), + quantity: 25, + unitPrice: 145000, + note: 'QC 필요', + ), + StockTransactionLine( + id: 4, + lineNo: 2, + product: StockTransactionProduct( + id: 4, + code: 'P-201', + name: 'Eco-200B', + vendor: StockTransactionVendorSummary(id: 3, name: '그린텍'), + uom: StockTransactionUomSummary(id: 1, name: 'EA'), + ), + quantity: 10, + unitPrice: 160000, + note: '', + ), + ], + customers: const [], + ); +} + +StockTransaction _createInboundTransaction3() { + return StockTransaction( + id: 3, + transactionNo: 'TX-20240310-004', + transactionDate: DateTime(2024, 3, 10), + type: StockTransactionType(id: _inboundTypeId, name: '입고'), + status: StockTransactionStatus(id: _statusCompleteId, name: '승인완료'), + warehouse: StockTransactionWarehouse(id: 3, code: 'WH-003', name: '대전 물류'), + createdBy: StockTransactionEmployee( + id: 3, + employeeNo: 'EMP-020', + name: '최검수', + ), + note: '완료', + lines: [ + StockTransactionLine( + id: 5, + lineNo: 1, + product: StockTransactionProduct( + id: 5, + code: 'P-300', + name: 'Delta-One', + vendor: StockTransactionVendorSummary(id: 4, name: '델타'), + uom: StockTransactionUomSummary(id: 2, name: 'SET'), + ), + quantity: 8, + unitPrice: 450000, + note: '설치 일정 확인', + ), + ], + customers: const [], + ); +} + +StockTransaction _createOutboundTransaction1() { + return StockTransaction( + id: 10, + transactionNo: 'TX-20240302-010', + transactionDate: DateTime(2024, 3, 2), + type: StockTransactionType(id: _outboundTypeId, name: '출고'), + status: StockTransactionStatus(id: _statusOutboundWaitId, name: '출고대기'), + warehouse: StockTransactionWarehouse(id: 4, code: 'WH-001', name: '서울 1창고'), + createdBy: StockTransactionEmployee( + id: 4, + employeeNo: 'EMP-030', + name: '이영희', + ), + note: '-', + lines: [ + StockTransactionLine( + id: 6, + lineNo: 1, + product: StockTransactionProduct( + id: 1, + code: 'P-100', + name: 'XR-5000', + vendor: StockTransactionVendorSummary(id: 1, name: '슈퍼벤더'), + uom: StockTransactionUomSummary(id: 1, name: 'EA'), + ), + quantity: 30, + unitPrice: 130000, + note: '긴급 출고', + ), + StockTransactionLine( + id: 7, + lineNo: 2, + product: StockTransactionProduct( + id: 2, + code: 'P-101', + name: 'XR-5001', + vendor: StockTransactionVendorSummary(id: 1, name: '슈퍼벤더'), + uom: StockTransactionUomSummary(id: 1, name: 'EA'), + ), + quantity: 20, + unitPrice: 118000, + note: '', + ), + ], + customers: [ + StockTransactionCustomer( + id: 1, + customer: StockTransactionCustomerSummary( + id: 1, + code: 'C-1001', + name: '슈퍼포트 파트너', + ), + note: '', + ), + StockTransactionCustomer( + id: 2, + customer: StockTransactionCustomerSummary( + id: 2, + code: 'C-1002', + name: '그린에너지', + ), + note: '', + ), + ], + ); +} + +StockTransaction _createOutboundTransaction2() { + return StockTransaction( + id: 11, + transactionNo: 'TX-20240304-005', + transactionDate: DateTime(2024, 3, 4), + type: StockTransactionType(id: _outboundTypeId, name: '출고'), + status: StockTransactionStatus(id: _statusOutboundDoneId, name: '출고완료'), + warehouse: StockTransactionWarehouse(id: 5, code: 'WH-002', name: '부산 센터'), + createdBy: StockTransactionEmployee( + id: 5, + employeeNo: 'EMP-040', + name: '강물류', + ), + note: '완납', + lines: [ + StockTransactionLine( + id: 8, + lineNo: 1, + product: StockTransactionProduct( + id: 3, + code: 'P-200', + name: 'Eco-200', + vendor: StockTransactionVendorSummary(id: 3, name: '그린텍'), + uom: StockTransactionUomSummary(id: 1, name: 'EA'), + ), + quantity: 15, + unitPrice: 150000, + note: '', + ), + ], + customers: [ + StockTransactionCustomer( + id: 3, + customer: StockTransactionCustomerSummary( + id: 3, + code: 'C-1010', + name: '테크솔루션', + ), + note: '', + ), + ], + ); +} + +StockTransaction _createOutboundTransaction3() { + return StockTransaction( + id: 12, + transactionNo: 'TX-20240309-012', + transactionDate: DateTime(2024, 3, 9), + type: StockTransactionType(id: _outboundTypeId, name: '출고'), + status: StockTransactionStatus(id: _statusDraftId, name: '작성중'), + warehouse: StockTransactionWarehouse(id: 6, code: 'WH-003', name: '대전 물류'), + createdBy: StockTransactionEmployee( + id: 6, + employeeNo: 'EMP-050', + name: '최준비', + ), + note: '운송배차 예정', + lines: [ + StockTransactionLine( + id: 9, + lineNo: 1, + product: StockTransactionProduct( + id: 5, + code: 'P-300', + name: 'Delta-One', + vendor: StockTransactionVendorSummary(id: 4, name: '델타'), + uom: StockTransactionUomSummary(id: 2, name: 'SET'), + ), + quantity: 6, + unitPrice: 460000, + note: '시연용', + ), + ], + customers: [ + StockTransactionCustomer( + id: 4, + customer: StockTransactionCustomerSummary( + id: 4, + code: 'C-1012', + name: '에이치솔루션', + ), + note: '', + ), + StockTransactionCustomer( + id: 5, + customer: StockTransactionCustomerSummary( + id: 5, + code: 'C-1013', + name: '블루하이드', + ), + note: '', + ), + ], + ); +} + +StockTransaction _createRentalTransaction1() { + return StockTransaction( + id: 20, + transactionNo: 'TX-20240305-030', + transactionDate: DateTime(2024, 3, 5), + type: StockTransactionType(id: _rentalRentTypeId, name: '대여'), + status: StockTransactionStatus(id: _statusRentalRentingId, name: '대여중'), + warehouse: StockTransactionWarehouse(id: 1, code: 'WH-001', name: '서울 1창고'), + createdBy: StockTransactionEmployee( + id: 7, + employeeNo: 'EMP-060', + name: '박대여', + ), + note: '장기 대여', + expectedReturnDate: DateTime(2024, 3, 12), + lines: [ + StockTransactionLine( + id: 10, + lineNo: 1, + product: StockTransactionProduct( + id: 1, + code: 'P-100', + name: 'XR-5000', + vendor: StockTransactionVendorSummary(id: 1, name: '슈퍼벤더'), + uom: StockTransactionUomSummary(id: 1, name: 'EA'), + ), + quantity: 10, + unitPrice: 120000, + note: '검수 예정', + ), + StockTransactionLine( + id: 11, + lineNo: 2, + product: StockTransactionProduct( + id: 6, + code: 'P-102', + name: 'XR-5002', + vendor: StockTransactionVendorSummary(id: 1, name: '슈퍼벤더'), + uom: StockTransactionUomSummary(id: 1, name: 'EA'), + ), + quantity: 5, + unitPrice: 110000, + note: '', + ), + ], + customers: [ + StockTransactionCustomer( + id: 6, + customer: StockTransactionCustomerSummary( + id: 1, + code: 'C-1001', + name: '슈퍼포트 파트너', + ), + note: '', + ), + ], + ); +} + +StockTransaction _createRentalTransaction2() { + return StockTransaction( + id: 21, + transactionNo: 'TX-20240308-014', + transactionDate: DateTime(2024, 3, 8), + type: StockTransactionType(id: _rentalRentTypeId, name: '대여'), + status: StockTransactionStatus(id: _statusRentalReturnWaitId, name: '반납대기'), + warehouse: StockTransactionWarehouse(id: 2, code: 'WH-002', name: '부산 센터'), + createdBy: StockTransactionEmployee( + id: 8, + employeeNo: 'EMP-070', + name: '이반납', + ), + note: '-', + expectedReturnDate: DateTime(2024, 3, 15), + lines: [ + StockTransactionLine( + id: 12, + lineNo: 1, + product: StockTransactionProduct( + id: 3, + code: 'P-200', + name: 'Eco-200', + vendor: StockTransactionVendorSummary(id: 3, name: '그린텍'), + uom: StockTransactionUomSummary(id: 1, name: 'EA'), + ), + quantity: 8, + unitPrice: 145000, + note: '', + ), + ], + customers: [ + StockTransactionCustomer( + id: 7, + customer: StockTransactionCustomerSummary( + id: 2, + code: 'C-1002', + name: '그린에너지', + ), + note: '', + ), + StockTransactionCustomer( + id: 8, + customer: StockTransactionCustomerSummary( + id: 3, + code: 'C-1010', + name: '테크솔루션', + ), + note: '', + ), + ], + ); +} + +StockTransaction _createRentalTransaction3() { + return StockTransaction( + id: 22, + transactionNo: 'TX-20240312-021', + transactionDate: DateTime(2024, 3, 12), + type: StockTransactionType(id: _rentalReturnTypeId, name: '반납'), + status: StockTransactionStatus(id: _statusRentalFinishedId, name: '완료'), + warehouse: StockTransactionWarehouse(id: 3, code: 'WH-003', name: '대전 물류'), + createdBy: StockTransactionEmployee( + id: 9, + employeeNo: 'EMP-080', + name: '최관리', + ), + note: '정상 반납', + expectedReturnDate: DateTime(2024, 3, 12), + lines: [ + StockTransactionLine( + id: 13, + lineNo: 1, + product: StockTransactionProduct( + id: 5, + code: 'P-300', + name: 'Delta-One', + vendor: StockTransactionVendorSummary(id: 4, name: '델타'), + uom: StockTransactionUomSummary(id: 2, name: 'SET'), + ), + quantity: 4, + unitPrice: 480000, + note: '', + ), + ], + customers: [ + StockTransactionCustomer( + id: 9, + customer: StockTransactionCustomerSummary( + id: 4, + code: 'C-1012', + name: '에이치솔루션', + ), + note: '', + ), + ], + ); +} diff --git a/test/helpers/test_app.dart b/test/helpers/test_app.dart index 3e54ae9..8c7efa3 100644 --- a/test/helpers/test_app.dart +++ b/test/helpers/test_app.dart @@ -11,7 +11,7 @@ Widget buildTestApp(Widget child, {PermissionManager? permissionManager}) { debugShowCheckedModeBanner: false, theme: SuperportShadTheme.light(), darkTheme: SuperportShadTheme.dark(), - home: Scaffold(body: child), + home: ScaffoldMessenger(child: Scaffold(body: child)), ), ); }