feat: 결재·마스터 실연동 업데이트

This commit is contained in:
JiWoong Sul
2025-10-14 18:10:24 +09:00
parent 1325109fba
commit 8067416c09
66 changed files with 2129 additions and 222 deletions

View File

@@ -0,0 +1,28 @@
import '../../domain/entities/approval_proceed_status.dart';
/// 결재 진행 가능 여부(can-proceed) 응답 DTO.
class ApprovalProceedStatusDto {
ApprovalProceedStatusDto({
required this.approvalId,
required this.canProceed,
this.reason,
});
final int approvalId;
final bool canProceed;
final String? reason;
factory ApprovalProceedStatusDto.fromJson(Map<String, dynamic> json) {
return ApprovalProceedStatusDto(
approvalId: json['id'] as int? ?? json['approval_id'] as int? ?? 0,
canProceed: json['can_proceed'] as bool? ?? false,
reason: json['reason'] as String?,
);
}
ApprovalProceedStatus toEntity() => ApprovalProceedStatus(
approvalId: approvalId,
canProceed: canProceed,
reason: reason,
);
}

View File

@@ -1,10 +1,13 @@
import 'package:dio/dio.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/core/network/api_routes.dart';
import '../../domain/entities/approval.dart';
import '../../domain/entities/approval_proceed_status.dart';
import '../../domain/repositories/approval_repository.dart';
import '../dtos/approval_dto.dart';
import '../dtos/approval_proceed_status_dto.dart';
/// 결재 API 엔드포인트를 호출하는 원격 저장소 구현체.
///
@@ -15,7 +18,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
final ApiClient _api;
static const _basePath = '/approvals';
static const _basePath = '${ApiRoutes.apiV1}/approvals';
/// 결재 목록을 조회한다. 필터 조건이 없으면 최신순 페이지를 반환한다.
@override
@@ -69,7 +72,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
@override
Future<List<ApprovalAction>> listActions({bool activeOnly = true}) async {
final response = await _api.get<Map<String, dynamic>>(
'/approval-actions',
'${ApiRoutes.apiV1}/approval-actions',
query: {'page': 1, 'page_size': 100, if (activeOnly) 'active': true},
options: Options(responseType: ResponseType.json),
);
@@ -85,7 +88,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
@override
Future<Approval> performStepAction(ApprovalStepActionInput input) async {
final response = await _api.post<Map<String, dynamic>>(
'/approval-steps/${input.stepId}/actions',
'${ApiRoutes.apiV1}/approval-steps/${input.stepId}/actions',
data: input.toPayload(),
options: Options(responseType: ResponseType.json),
);
@@ -102,7 +105,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
@override
Future<Approval> assignSteps(ApprovalStepAssignmentInput input) async {
final response = await _api.post<Map<String, dynamic>>(
'/approvals/${input.approvalId}/steps',
'${ApiRoutes.apiV1}/approvals/${input.approvalId}/steps',
data: input.toPayload(),
options: Options(responseType: ResponseType.json),
);
@@ -115,6 +118,17 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
return ApprovalDto.fromJson(approvalJson).toEntity();
}
/// 결재가 다음 단계로 진행 가능한지 확인한다.
@override
Future<ApprovalProceedStatus> canProceed(int id) async {
final response = await _api.get<Map<String, dynamic>>(
'$_basePath/$id/can-proceed',
options: Options(responseType: ResponseType.json),
);
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
return ApprovalProceedStatusDto.fromJson(data).toEntity();
}
/// 새로운 결재를 생성한다.
@override
Future<Approval> create(ApprovalInput input) async {

View File

@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import '../../../../core/common/models/paginated_result.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/network/api_routes.dart';
import '../../domain/entities/approval_template.dart';
import '../../domain/repositories/approval_template_repository.dart';
import '../dtos/approval_template_dto.dart';
@@ -16,7 +17,7 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
final ApiClient _api;
static const _basePath = '/approval-templates';
static const _basePath = '${ApiRoutes.apiV1}/approval-templates';
/// 결재 템플릿 목록을 조회한다. 검색/활성 여부 필터를 지원한다.
@override

View File

@@ -0,0 +1,14 @@
/// 결재 진행 가능 여부(can-proceed) 응답 엔티티.
///
/// - 백엔드 `GET /approvals/{id}/can-proceed` 결과를 표현한다.
class ApprovalProceedStatus {
const ApprovalProceedStatus({
required this.approvalId,
required this.canProceed,
this.reason,
});
final int approvalId;
final bool canProceed;
final String? reason;
}

View File

@@ -1,6 +1,7 @@
import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../entities/approval.dart';
import '../entities/approval_proceed_status.dart';
/// 결재 도메인에서 사용하는 저장소 인터페이스.
///
@@ -34,6 +35,9 @@ abstract class ApprovalRepository {
/// 결재 단계 일괄 생성/재배치
Future<Approval> assignSteps(ApprovalStepAssignmentInput input);
/// 결재가 다음 단계로 진행 가능한지 여부를 확인한다.
Future<ApprovalProceedStatus> canProceed(int id);
/// 결재를 생성한다.
Future<Approval> create(ApprovalInput input);

View File

@@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/core/network/api_routes.dart';
import '../../domain/entities/approval_history_record.dart';
import '../../domain/repositories/approval_history_repository.dart';
@@ -13,7 +14,7 @@ class ApprovalHistoryRepositoryRemote implements ApprovalHistoryRepository {
final ApiClient _api;
static const _basePath = '/approval-histories';
static const _basePath = '${ApiRoutes.apiV1}/approval-histories';
/// 결재 이력 목록을 조회한다.
@override

View File

@@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/failure.dart';
import '../../domain/entities/approval_history_record.dart';
import '../../domain/repositories/approval_history_repository.dart';
@@ -59,8 +60,9 @@ class ApprovalHistoryController extends ChangeNotifier {
);
_result = response;
_pageSize = response.pageSize;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoading = false;
notifyListeners();

View File

@@ -1,7 +1,11 @@
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/failure.dart';
import '../../../inventory/lookups/domain/entities/lookup_item.dart';
import '../../../inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
import '../../domain/entities/approval.dart';
import '../../domain/entities/approval_proceed_status.dart';
import '../../domain/entities/approval_template.dart';
import '../../domain/repositories/approval_repository.dart';
import '../../domain/repositories/approval_template_repository.dart';
@@ -23,6 +27,14 @@ const Map<ApprovalStepActionType, List<String>> _actionAliases = {
ApprovalStepActionType.comment: ['comment', '코멘트', '의견'],
};
const Map<ApprovalStatusFilter, String> _defaultStatusCodes = {
ApprovalStatusFilter.pending: 'pending',
ApprovalStatusFilter.inProgress: 'in_progress',
ApprovalStatusFilter.onHold: 'on_hold',
ApprovalStatusFilter.approved: 'approved',
ApprovalStatusFilter.rejected: 'rejected',
};
/// 결재 목록 및 상세 화면 상태 컨트롤러
///
/// - 목록 조회/필터 상태와 선택된 결재 상세 데이터를 관리한다.
@@ -31,11 +43,14 @@ class ApprovalController extends ChangeNotifier {
ApprovalController({
required ApprovalRepository approvalRepository,
required ApprovalTemplateRepository templateRepository,
InventoryLookupRepository? lookupRepository,
}) : _repository = approvalRepository,
_templateRepository = templateRepository;
_templateRepository = templateRepository,
_lookupRepository = lookupRepository;
final ApprovalRepository _repository;
final ApprovalTemplateRepository _templateRepository;
final InventoryLookupRepository? _lookupRepository;
PaginatedResult<Approval>? _result;
Approval? _selected;
@@ -47,6 +62,7 @@ class ApprovalController extends ChangeNotifier {
bool _isLoadingTemplates = false;
bool _isApplyingTemplate = false;
int? _applyingTemplateId;
ApprovalProceedStatus? _proceedStatus;
String? _errorMessage;
String _query = '';
ApprovalStatusFilter _statusFilter = ApprovalStatusFilter.all;
@@ -54,6 +70,12 @@ class ApprovalController extends ChangeNotifier {
DateTime? _toDate;
List<ApprovalAction> _actions = const [];
List<ApprovalTemplate> _templates = const [];
final Map<String, LookupItem> _statusLookup = {};
final Map<String, String> _statusCodeAliases = Map.fromEntries(
_defaultStatusCodes.entries.map(
(entry) => MapEntry(entry.value, entry.value),
),
);
PaginatedResult<Approval>? get result => _result;
Approval? get selected => _selected;
@@ -73,6 +95,17 @@ class ApprovalController extends ChangeNotifier {
bool get isLoadingTemplates => _isLoadingTemplates;
bool get isApplyingTemplate => _isApplyingTemplate;
int? get applyingTemplateId => _applyingTemplateId;
ApprovalProceedStatus? get proceedStatus => _proceedStatus;
bool get canProceedSelected => _proceedStatus?.canProceed ?? true;
String? get cannotProceedReason {
final reason = _proceedStatus?.reason?.trim();
if (reason == null || reason.isEmpty) {
return null;
}
return reason;
}
Map<String, LookupItem> get statusLookup => _statusLookup;
/// 필터 조건과 페이지 정보를 기반으로 결재 목록을 조회한다.
///
@@ -83,14 +116,7 @@ class ApprovalController extends ChangeNotifier {
_errorMessage = null;
notifyListeners();
try {
final statusParam = switch (_statusFilter) {
ApprovalStatusFilter.all => null,
ApprovalStatusFilter.pending => 'pending',
ApprovalStatusFilter.inProgress => 'in_progress',
ApprovalStatusFilter.onHold => 'on_hold',
ApprovalStatusFilter.approved => 'approved',
ApprovalStatusFilter.rejected => 'rejected',
};
final statusParam = _statusCodeFor(_statusFilter);
final response = await _repository.list(
page: page,
pageSize: _result?.pageSize ?? 20,
@@ -106,10 +132,12 @@ class ApprovalController extends ChangeNotifier {
final exists = response.items.any((item) => item.id == _selected?.id);
if (!exists) {
_selected = null;
_proceedStatus = null;
}
}
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoadingList = false;
notifyListeners();
@@ -129,14 +157,78 @@ class ApprovalController extends ChangeNotifier {
try {
final items = await _repository.listActions(activeOnly: true);
_actions = items;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoadingActions = false;
notifyListeners();
}
}
Future<void> loadStatusLookups() async {
final repository = _lookupRepository;
if (repository == null) {
return;
}
try {
final items = await repository.fetchApprovalStatuses();
_statusLookup
..clear()
..addEntries(
items.map(
(item) => MapEntry(
(item.code ?? item.name).toLowerCase(),
item,
),
),
);
for (final entry in _defaultStatusCodes.entries) {
final code = entry.value.toLowerCase();
final lookup = _statusLookup[code];
if (lookup != null) {
_statusCodeAliases[entry.value] = lookup.code?.toLowerCase() ?? code;
}
}
notifyListeners();
} catch (_) {
// 실패 시 기본 라벨 사용
}
}
String statusLabel(ApprovalStatusFilter filter) {
if (filter == ApprovalStatusFilter.all) {
return '전체 상태';
}
final code = _statusCodeFor(filter);
if (code != null) {
final normalized = code.toLowerCase();
final lookup = _statusLookup[normalized];
if (lookup != null && lookup.name.isNotEmpty) {
return lookup.name;
}
}
return switch (filter) {
ApprovalStatusFilter.pending => '승인대기',
ApprovalStatusFilter.inProgress => '진행중',
ApprovalStatusFilter.onHold => '보류',
ApprovalStatusFilter.approved => '승인완료',
ApprovalStatusFilter.rejected => '반려',
ApprovalStatusFilter.all => '전체 상태',
};
}
String? _statusCodeFor(ApprovalStatusFilter filter) {
if (filter == ApprovalStatusFilter.all) {
return null;
}
final defaultCode = _defaultStatusCodes[filter];
if (defaultCode == null) {
return null;
}
return _statusCodeAliases[defaultCode] ?? defaultCode;
}
/// 활성화된 결재 템플릿 목록을 조회해 캐싱한다.
///
/// 템플릿이 비어 있거나 [force]가 `true`이면 API를 다시 호출한다.
@@ -154,8 +246,9 @@ class ApprovalController extends ChangeNotifier {
isActive: true,
);
_templates = result.items;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoadingTemplates = false;
notifyListeners();
@@ -169,6 +262,7 @@ class ApprovalController extends ChangeNotifier {
Future<void> selectApproval(int id) async {
_isLoadingDetail = true;
_errorMessage = null;
_proceedStatus = null;
notifyListeners();
try {
final detail = await _repository.fetchDetail(
@@ -177,8 +271,12 @@ class ApprovalController extends ChangeNotifier {
includeHistories: true,
);
_selected = detail;
} catch (e) {
_errorMessage = e.toString();
if (detail.id != null) {
await _loadProceedStatus(detail.id!);
}
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoadingDetail = false;
notifyListeners();
@@ -188,6 +286,7 @@ class ApprovalController extends ChangeNotifier {
/// 선택된 결재 상세를 비우고 화면을 초기화한다.
void clearSelection() {
_selected = null;
_proceedStatus = null;
notifyListeners();
}
@@ -200,6 +299,12 @@ class ApprovalController extends ChangeNotifier {
required ApprovalStepActionType type,
String? note,
}) async {
final approvalId = _selected?.id;
if (approvalId == null) {
_errorMessage = '선택한 결재 정보가 없어 단계를 처리할 수 없습니다.';
notifyListeners();
return false;
}
if (step.id == null) {
_errorMessage = '단계 식별자가 없어 행위를 수행할 수 없습니다.';
notifyListeners();
@@ -217,6 +322,14 @@ class ApprovalController extends ChangeNotifier {
_errorMessage = null;
notifyListeners();
try {
final proceedStatus = await _repository.canProceed(approvalId);
_proceedStatus = proceedStatus;
if (!proceedStatus.canProceed) {
_errorMessage = proceedStatus.reason ??
'결재 단계가 현재 상태에서 진행될 수 없습니다.';
return false;
}
final sanitizedNote = note?.trim();
final updated = await _repository.performStepAction(
ApprovalStepActionInput(
@@ -232,9 +345,15 @@ class ApprovalController extends ChangeNotifier {
.toList();
_result = _result!.copyWith(items: items);
}
if (updated.id != null) {
await _loadProceedStatus(updated.id!);
} else {
await _loadProceedStatus(approvalId);
}
return true;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
return false;
} finally {
_isPerformingAction = false;
@@ -291,8 +410,9 @@ class ApprovalController extends ChangeNotifier {
_result = _result!.copyWith(items: items);
}
return true;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
return false;
} finally {
_isApplyingTemplate = false;
@@ -348,4 +468,15 @@ class ApprovalController extends ChangeNotifier {
}
return null;
}
Future<void> _loadProceedStatus(int approvalId) async {
try {
final status = await _repository.canProceed(approvalId);
_proceedStatus = status;
} catch (error) {
_proceedStatus = null;
final failure = Failure.from(error);
_errorMessage ??= failure.describe();
}
}
}

View File

@@ -7,6 +7,7 @@ import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../core/config/environment.dart';
import '../../../../core/constants/app_sections.dart';
import '../../../../core/permissions/permission_manager.dart';
import '../../../../core/permissions/permission_resources.dart';
import '../../../../widgets/app_layout.dart';
import '../../../../widgets/components/feedback.dart';
import '../../../../widgets/components/filter_bar.dart';
@@ -18,9 +19,10 @@ import '../../domain/entities/approval.dart';
import '../../domain/entities/approval_template.dart';
import '../../domain/repositories/approval_repository.dart';
import '../../domain/repositories/approval_template_repository.dart';
import '../../../inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
import '../controllers/approval_controller.dart';
const _approvalsResourcePath = '/approvals/requests';
const _approvalsResourcePath = PermissionResources.approvals;
/// 결재 관리 최상위 페이지.
///
@@ -85,11 +87,15 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
_controller = ApprovalController(
approvalRepository: GetIt.I<ApprovalRepository>(),
templateRepository: GetIt.I<ApprovalTemplateRepository>(),
lookupRepository: GetIt.I.isRegistered<InventoryLookupRepository>()
? GetIt.I<InventoryLookupRepository>()
: null,
)..addListener(_handleControllerUpdate);
WidgetsBinding.instance.addPostFrameCallback((_) async {
await Future.wait([
_controller.loadActionOptions(),
_controller.loadTemplates(),
_controller.loadStatusLookups(),
]);
await _controller.fetch();
});
@@ -335,6 +341,8 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
selectedTemplateId: _selectedTemplateId,
canPerformStepActions: canPerformStepActions,
canApplyTemplate: canManageTemplates,
canProceed: _controller.canProceedSelected,
cannotProceedReason: _controller.cannotProceedReason,
dateFormat: _dateTimeFormat,
onRefresh: () {
final id = selectedApproval?.id;
@@ -660,22 +668,8 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
return confirmed ?? false;
}
String _statusLabel(ApprovalStatusFilter filter) {
switch (filter) {
case ApprovalStatusFilter.all:
return '전체 상태';
case ApprovalStatusFilter.pending:
return '대기';
case ApprovalStatusFilter.inProgress:
return '진행중';
case ApprovalStatusFilter.onHold:
return '보류';
case ApprovalStatusFilter.approved:
return '승인';
case ApprovalStatusFilter.rejected:
return '반려';
}
}
String _statusLabel(ApprovalStatusFilter filter) =>
_controller.statusLabel(filter);
String _dialogTitle(ApprovalStepActionType type) {
switch (type) {
@@ -827,6 +821,8 @@ class _DetailSection extends StatelessWidget {
required this.selectedTemplateId,
required this.canPerformStepActions,
required this.canApplyTemplate,
required this.canProceed,
required this.cannotProceedReason,
required this.dateFormat,
required this.onRefresh,
required this.onClose,
@@ -849,6 +845,8 @@ class _DetailSection extends StatelessWidget {
final int? selectedTemplateId;
final bool canPerformStepActions;
final bool canApplyTemplate;
final bool canProceed;
final String? cannotProceedReason;
final intl.DateFormat dateFormat;
final VoidCallback onRefresh;
final VoidCallback? onClose;
@@ -929,6 +927,8 @@ class _DetailSection extends StatelessWidget {
selectedTemplateId: selectedTemplateId,
canPerformStepActions: canPerformStepActions,
canApplyTemplate: canApplyTemplate,
canProceed: canProceed,
cannotProceedReason: cannotProceedReason,
onSelectTemplate: onSelectTemplate,
onApplyTemplate: onApplyTemplate,
onReloadTemplates: onReloadTemplates,
@@ -1028,6 +1028,8 @@ class _StepTab extends StatelessWidget {
required this.selectedTemplateId,
required this.canPerformStepActions,
required this.canApplyTemplate,
required this.canProceed,
required this.cannotProceedReason,
required this.onSelectTemplate,
required this.onApplyTemplate,
required this.onReloadTemplates,
@@ -1048,6 +1050,8 @@ class _StepTab extends StatelessWidget {
final int? selectedTemplateId;
final bool canPerformStepActions;
final bool canApplyTemplate;
final bool canProceed;
final String? cannotProceedReason;
final void Function(int?) onSelectTemplate;
final void Function(int templateId) onApplyTemplate;
final VoidCallback onReloadTemplates;
@@ -1097,6 +1101,14 @@ class _StepTab extends StatelessWidget {
style: theme.textTheme.muted,
),
),
if (!canProceed)
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
child: Text(
cannotProceedReason ?? '현재는 결재 단계를 진행할 수 없습니다.',
style: theme.textTheme.muted,
),
),
if (steps.isEmpty)
Expanded(
child: Center(
@@ -1112,6 +1124,8 @@ class _StepTab extends StatelessWidget {
final disabledReason = _disabledReason(
step,
canPerformStepActions,
canProceed,
cannotProceedReason,
);
final isProcessingStep =
isPerformingAction && processingStepId == step.id;
@@ -1284,7 +1298,12 @@ class _StepTab extends StatelessWidget {
return button;
}
String? _disabledReason(ApprovalStep step, bool canPerformStepActions) {
String? _disabledReason(
ApprovalStep step,
bool canPerformStepActions,
bool canProceed,
String? cannotProceedReason,
) {
if (!canPerformStepActions) {
return '결재 행위를 수행할 권한이 없습니다.';
}
@@ -1294,6 +1313,9 @@ class _StepTab extends StatelessWidget {
if (!hasActionOptions) {
return '사용 가능한 결재 행위가 없습니다.';
}
if (!canProceed) {
return cannotProceedReason ?? '현재는 결재 단계를 진행할 수 없습니다.';
}
if (isPerformingAction && processingStepId != step.id) {
return '다른 결재 단계를 처리 중입니다.';
}

View File

@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/core/network/api_routes.dart';
import 'package:superport_v2/features/approvals/step/domain/entities/approval_step_record.dart';
import 'package:superport_v2/features/approvals/step/domain/repositories/approval_step_repository.dart';
import '../dtos/approval_step_record_dto.dart';
@@ -14,7 +15,7 @@ class ApprovalStepRepositoryRemote implements ApprovalStepRepository {
final ApiClient _api;
static const _basePath = '/approval-steps';
static const _basePath = '${ApiRoutes.apiV1}/approval-steps';
/// 결재 단계 목록을 조회한다.
@override

View File

@@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/failure.dart';
import '../../domain/entities/approval_step_input.dart';
import '../../domain/entities/approval_step_record.dart';
@@ -49,8 +50,9 @@ class ApprovalStepController extends ChangeNotifier {
approverId: _approverId,
);
_result = response;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoading = false;
notifyListeners();
@@ -86,8 +88,9 @@ class ApprovalStepController extends ChangeNotifier {
final detail = await _repository.fetchDetail(id);
_selected = detail;
return detail;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
return null;
} finally {
_isLoadingDetail = false;
@@ -128,8 +131,9 @@ class ApprovalStepController extends ChangeNotifier {
final nextPage = _result?.page ?? 1;
await fetch(page: nextPage);
return created;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
return null;
} finally {
_isSaving = false;
@@ -150,8 +154,9 @@ class ApprovalStepController extends ChangeNotifier {
final nextPage = _result?.page ?? 1;
await fetch(page: nextPage);
return updated;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
return null;
} finally {
_isSaving = false;
@@ -181,8 +186,9 @@ class ApprovalStepController extends ChangeNotifier {
_result = _result!.copyWith(items: items);
}
return true;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
return false;
} finally {
_isSaving = false;
@@ -210,8 +216,9 @@ class ApprovalStepController extends ChangeNotifier {
_result = _result!.copyWith(items: items);
}
return record;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
return null;
} finally {
_isSaving = false;

View File

@@ -6,6 +6,7 @@ import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../../core/config/environment.dart';
import '../../../../../core/constants/app_sections.dart';
import '../../../../../core/permissions/permission_manager.dart';
import '../../../../../core/permissions/permission_resources.dart';
import '../../../../../widgets/app_layout.dart';
import '../../../../../widgets/components/filter_bar.dart';
import '../../../../../widgets/components/superport_dialog.dart';
@@ -15,7 +16,7 @@ import '../../domain/entities/approval_step_input.dart';
import '../../domain/entities/approval_step_record.dart';
import '../../domain/repositories/approval_step_repository.dart';
const String _stepResourcePath = '/approvals/steps';
const String _stepResourcePath = PermissionResources.approvalSteps;
/// 결재 단계 관리 진입 페이지. 기능 플래그에 따라 실제 화면 또는 준비중 화면을 노출한다.
class ApprovalStepPage extends StatelessWidget {

View File

@@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/failure.dart';
import '../../../domain/entities/approval_template.dart';
import '../../../domain/repositories/approval_template_repository.dart';
@@ -54,8 +55,9 @@ class ApprovalTemplateController extends ChangeNotifier {
);
_result = response;
_pageSize = response.pageSize;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoading = false;
notifyListeners();
@@ -83,8 +85,9 @@ class ApprovalTemplateController extends ChangeNotifier {
try {
final detail = await _repository.fetchDetail(id, includeSteps: true);
return detail;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
}
@@ -100,8 +103,9 @@ class ApprovalTemplateController extends ChangeNotifier {
final created = await _repository.create(input, steps: steps);
await fetch(page: 1);
return created;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
@@ -120,8 +124,9 @@ class ApprovalTemplateController extends ChangeNotifier {
final updated = await _repository.update(id, input, steps: steps);
await fetch(page: _result?.page ?? 1);
return updated;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
@@ -136,8 +141,9 @@ class ApprovalTemplateController extends ChangeNotifier {
await _repository.delete(id);
await fetch(page: _result?.page ?? 1);
return true;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return false;
} finally {
@@ -152,8 +158,9 @@ class ApprovalTemplateController extends ChangeNotifier {
final restored = await _repository.restore(id);
await fetch(page: _result?.page ?? 1);
return restored;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {

View File

@@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/core/network/api_routes.dart';
import '../../domain/entities/customer.dart';
import '../../domain/repositories/customer_repository.dart';
@@ -12,7 +13,7 @@ class CustomerRepositoryRemote implements CustomerRepository {
final ApiClient _api;
static const _basePath = '/customers';
static const _basePath = '${ApiRoutes.apiV1}/customers';
/// 고객 목록을 조회한다.
@override
@@ -39,6 +40,17 @@ class CustomerRepositoryRemote implements CustomerRepository {
return CustomerDto.parsePaginated(response.data ?? const {});
}
@override
Future<Customer> fetchDetail(int id, {bool includeZipcode = true}) async {
final response = await _api.get<Map<String, dynamic>>(
'$_basePath/$id',
query: {if (includeZipcode) 'include': 'zipcode'},
options: Options(responseType: ResponseType.json),
);
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
return CustomerDto.fromJson(data).toEntity();
}
/// 고객을 생성한다.
@override
Future<Customer> create(CustomerInput input) async {

View File

@@ -14,6 +14,9 @@ abstract class CustomerRepository {
bool? isActive,
});
/// 고객 단건 상세를 조회한다.
Future<Customer> fetchDetail(int id, {bool includeZipcode = true});
/// 고객을 생성한다.
Future<Customer> create(CustomerInput input);

View File

@@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/failure.dart';
import '../../domain/entities/customer.dart';
import '../../domain/repositories/customer_repository.dart';
@@ -78,8 +79,9 @@ class CustomerController extends ChangeNotifier {
if (response.pageSize > 0 && response.pageSize != _pageSize) {
_pageSize = response.pageSize;
}
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoading = false;
notifyListeners();
@@ -129,8 +131,9 @@ class CustomerController extends ChangeNotifier {
final created = await _repository.create(input);
await fetch(page: 1);
return created;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
@@ -145,8 +148,9 @@ class CustomerController extends ChangeNotifier {
final updated = await _repository.update(id, input);
await fetch(page: _result?.page ?? 1);
return updated;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
@@ -161,8 +165,9 @@ class CustomerController extends ChangeNotifier {
await _repository.delete(id);
await fetch(page: _result?.page ?? 1);
return true;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return false;
} finally {
@@ -177,8 +182,9 @@ class CustomerController extends ChangeNotifier {
final restored = await _repository.restore(id);
await fetch(page: _result?.page ?? 1);
return restored;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {

View File

@@ -32,7 +32,9 @@ class GroupDto {
return GroupDto(
id: json['id'] as int?,
groupName: json['group_name'] as String,
description: json['description'] as String?,
description:
json['description'] as String? ??
json['group_description'] as String?,
isDefault: (json['is_default'] as bool?) ?? false,
isActive: (json['is_active'] as bool?) ?? true,
isDeleted: (json['is_deleted'] as bool?) ?? false,

View File

@@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/core/network/api_routes.dart';
import '../../domain/entities/group.dart';
import '../../domain/repositories/group_repository.dart';
@@ -12,7 +13,7 @@ class GroupRepositoryRemote implements GroupRepository {
final ApiClient _api;
static const _basePath = '/groups';
static const _basePath = '${ApiRoutes.apiV1}/groups';
/// 그룹 목록을 조회한다.
@override
@@ -22,7 +23,16 @@ class GroupRepositoryRemote implements GroupRepository {
String? query,
bool? isDefault,
bool? isActive,
bool includePermissions = false,
bool includeEmployees = false,
}) async {
final includeParts = <String>[];
if (includePermissions) {
includeParts.add('permissions');
}
if (includeEmployees) {
includeParts.add('employees');
}
final response = await _api.get<Map<String, dynamic>>(
_basePath,
query: {
@@ -31,6 +41,7 @@ class GroupRepositoryRemote implements GroupRepository {
if (query != null && query.isNotEmpty) 'q': query,
if (isDefault != null) 'is_default': isDefault,
if (isActive != null) 'is_active': isActive,
if (includeParts.isNotEmpty) 'include': includeParts.join(','),
},
options: Options(responseType: ResponseType.json),
);

View File

@@ -10,6 +10,8 @@ abstract class GroupRepository {
String? query,
bool? isDefault,
bool? isActive,
bool includePermissions = false,
bool includeEmployees = false,
});
/// 그룹 신규 등록

View File

@@ -1,8 +1,13 @@
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/failure.dart';
import 'package:superport_v2/core/permissions/permission_manager.dart';
import '../../domain/entities/group.dart';
import '../../domain/repositories/group_repository.dart';
import '../../../group_permission/application/permission_synchronizer.dart';
import '../../../group_permission/domain/repositories/group_permission_repository.dart';
/// 기본 그룹 여부 필터.
enum GroupDefaultFilter { all, defaultOnly, nonDefault }
@@ -15,10 +20,17 @@ enum GroupStatusFilter { all, activeOnly, inactiveOnly }
/// - 목록 조회 및 필터, 페이징 상태를 담당한다.
/// - 생성/수정/삭제/복구 요청을 래핑하여 UI와 통신한다.
class GroupController extends ChangeNotifier {
GroupController({required GroupRepository repository})
: _repository = repository;
GroupController({
required GroupRepository repository,
GroupPermissionRepository? permissionRepository,
PermissionManager? permissionManager,
}) : _repository = repository,
_permissionRepository = permissionRepository,
_permissionManager = permissionManager;
final GroupRepository _repository;
final GroupPermissionRepository? _permissionRepository;
final PermissionManager? _permissionManager;
PaginatedResult<Group>? _result;
bool _isLoading = false;
@@ -60,8 +72,9 @@ class GroupController extends ChangeNotifier {
isActive: isActive,
);
_result = response;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoading = false;
notifyListeners();
@@ -92,9 +105,11 @@ class GroupController extends ChangeNotifier {
try {
final created = await _repository.create(input);
await fetch(page: 1);
await _maybeSync(created.id);
return created;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
@@ -108,9 +123,11 @@ class GroupController extends ChangeNotifier {
try {
final updated = await _repository.update(id, input);
await fetch(page: _result?.page ?? 1);
await _maybeSync(updated.id);
return updated;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
@@ -124,9 +141,11 @@ class GroupController extends ChangeNotifier {
try {
await _repository.delete(id);
await fetch(page: _result?.page ?? 1);
await _maybeSync(id);
return true;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return false;
} finally {
@@ -140,9 +159,11 @@ class GroupController extends ChangeNotifier {
try {
final restored = await _repository.restore(id);
await fetch(page: _result?.page ?? 1);
await _maybeSync(restored.id);
return restored;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
@@ -161,4 +182,28 @@ class GroupController extends ChangeNotifier {
_isSubmitting = value;
notifyListeners();
}
Future<void> _maybeSync(int? groupId) async {
if (groupId == null) {
return;
}
await _syncPermissionsForGroup(groupId);
}
Future<void> _syncPermissionsForGroup(int groupId) async {
final repository = _permissionRepository;
final manager = _permissionManager;
if (repository == null || manager == null) {
return;
}
try {
final synchronizer = PermissionSynchronizer(
repository: repository,
manager: manager,
);
await synchronizer.syncForGroup(groupId);
} catch (_) {
// 권한 동기화 실패는 UI 동작에 영향을 주지 않도록 무시한다.
}
}
}

View File

@@ -0,0 +1,51 @@
import 'package:superport_v2/core/permissions/permission_manager.dart';
import '../domain/entities/group_permission.dart';
import '../domain/mappers/group_permission_mapper.dart';
import '../domain/repositories/group_permission_repository.dart';
/// 서버에서 그룹 메뉴 권한을 조회해 [PermissionManager]에 반영하는 동기화기.
class PermissionSynchronizer {
PermissionSynchronizer({
required GroupPermissionRepository repository,
required PermissionManager manager,
this.pageSize = 200,
}) : _repository = repository,
_manager = manager;
final GroupPermissionRepository _repository;
final PermissionManager _manager;
final int pageSize;
/// 지정한 [groupId]의 메뉴 권한을 조회해 [PermissionManager]에 적용한다.
Future<void> syncForGroup(int groupId) async {
final collected = <GroupPermission>[];
var page = 1;
while (true) {
final response = await _repository.list(
page: page,
pageSize: pageSize,
groupId: groupId,
includeDeleted: false,
isActive: true,
);
collected.addAll(response.items);
final currentPageSize = response.pageSize == 0
? response.items.length
: response.pageSize;
if (currentPageSize == 0) {
break;
}
final fetched = page * currentPageSize;
if (fetched >= response.total) {
break;
}
page += 1;
}
final permissionMap = buildPermissionMap(collected);
_manager.applyServerPermissions(permissionMap);
}
}

View File

@@ -111,22 +111,39 @@ class GroupPermissionGroupDto {
/// 권한 대상 메뉴 정보를 담는 DTO.
class GroupPermissionMenuDto {
GroupPermissionMenuDto({required this.id, required this.menuName});
GroupPermissionMenuDto({
required this.id,
required this.menuCode,
required this.menuName,
this.path,
});
final int id;
final String menuCode;
final String menuName;
final String? path;
/// JSON에서 메뉴 정보를 파싱한다.
factory GroupPermissionMenuDto.fromJson(Map<String, dynamic> json) {
final fallbackName =
json['menu_name'] as String? ?? json['name'] as String? ?? '-';
final code =
json['menu_code'] as String? ?? json['code'] as String? ?? fallbackName;
return GroupPermissionMenuDto(
id: json['id'] as int? ?? json['menu_id'] as int,
menuName: json['menu_name'] as String? ?? json['name'] as String? ?? '-',
menuCode: code,
menuName: fallbackName,
path: json['path'] as String?,
);
}
/// DTO를 [GroupPermissionMenu] 엔티티로 변환한다.
GroupPermissionMenu toEntity() =>
GroupPermissionMenu(id: id, menuName: menuName);
GroupPermissionMenu toEntity() => GroupPermissionMenu(
id: id,
menuCode: menuCode,
menuName: menuName,
path: path,
);
}
/// 문자열/DateTime 값을 파싱해 [DateTime]으로 변환한다.

View File

@@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/core/network/api_routes.dart';
import '../../domain/entities/group_permission.dart';
import '../../domain/repositories/group_permission_repository.dart';
@@ -13,7 +14,7 @@ class GroupPermissionRepositoryRemote implements GroupPermissionRepository {
final ApiClient _api;
static const _basePath = '/group-menu-permissions';
static const _basePath = '${ApiRoutes.apiV1}/group-menu-permissions';
/// 그룹 권한 목록을 조회한다.
@override

View File

@@ -70,10 +70,17 @@ class GroupPermissionGroup {
}
class GroupPermissionMenu {
GroupPermissionMenu({required this.id, required this.menuName});
GroupPermissionMenu({
required this.id,
required this.menuCode,
required this.menuName,
this.path,
});
final int id;
final String menuCode;
final String menuName;
final String? path;
}
/// 그룹 권한 생성/수정 입력 모델

View File

@@ -0,0 +1,34 @@
import 'package:superport_v2/core/permissions/permission_manager.dart';
import 'package:superport_v2/core/permissions/permission_resources.dart';
import '../entities/group_permission.dart';
/// 그룹-메뉴 권한 목록을 [PermissionManager]에 적용할 수 있는 맵으로 변환한다.
///
/// - 메뉴 경로([GroupPermissionMenu.path])가 비어 있으면 해당 항목은 건너뛴다.
/// - 읽기 권한은 [PermissionAction.view]로 매핑하고, CRUD 권한은 각각 대응한다.
Map<String, Set<PermissionAction>> buildPermissionMap(
Iterable<GroupPermission> permissions,
) {
final result = <String, Set<PermissionAction>>{};
for (final permission in permissions) {
final path = PermissionResources.normalize(permission.menu.path ?? '');
if (path.isEmpty) {
continue;
}
final actions = result.putIfAbsent(path, () => <PermissionAction>{});
if (permission.canRead) {
actions.add(PermissionAction.view);
}
if (permission.canCreate) {
actions.add(PermissionAction.create);
}
if (permission.canUpdate) {
actions.add(PermissionAction.edit);
}
if (permission.canDelete) {
actions.add(PermissionAction.delete);
}
}
return result;
}

View File

@@ -1,6 +1,9 @@
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/failure.dart';
import '../../../../../core/permissions/permission_manager.dart';
import '../../application/permission_synchronizer.dart';
import '../../../group/domain/entities/group.dart';
import '../../../group/domain/repositories/group_repository.dart';
import '../../../menu/domain/entities/menu.dart';
@@ -20,13 +23,16 @@ class GroupPermissionController extends ChangeNotifier {
required GroupPermissionRepository permissionRepository,
required GroupRepository groupRepository,
required MenuRepository menuRepository,
PermissionManager? permissionManager,
}) : _permissionRepository = permissionRepository,
_groupRepository = groupRepository,
_menuRepository = menuRepository;
_menuRepository = menuRepository,
_permissionManager = permissionManager;
final GroupPermissionRepository _permissionRepository;
final GroupRepository _groupRepository;
final MenuRepository _menuRepository;
final PermissionManager? _permissionManager;
PaginatedResult<GroupPermission>? _result;
bool _isLoading = false;
@@ -63,8 +69,9 @@ class GroupPermissionController extends ChangeNotifier {
_groups
..clear()
..addAll(response.items);
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoadingGroups = false;
notifyListeners();
@@ -84,8 +91,9 @@ class GroupPermissionController extends ChangeNotifier {
_menus
..clear()
..addAll(response.items);
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoadingMenus = false;
notifyListeners();
@@ -112,8 +120,9 @@ class GroupPermissionController extends ChangeNotifier {
includeDeleted: _includeDeleted,
);
_result = response;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoading = false;
notifyListeners();
@@ -150,9 +159,11 @@ class GroupPermissionController extends ChangeNotifier {
try {
final created = await _permissionRepository.create(input);
await fetch(page: 1);
await _syncPermissionsForGroup(input.groupId);
return created;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
@@ -166,9 +177,11 @@ class GroupPermissionController extends ChangeNotifier {
try {
final updated = await _permissionRepository.update(id, input);
await fetch(page: _result?.page ?? 1);
await _syncPermissionsForGroup(input.groupId);
return updated;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
@@ -180,11 +193,16 @@ class GroupPermissionController extends ChangeNotifier {
Future<bool> delete(int id) async {
_setSubmitting(true);
try {
final groupId = _resolveGroupIdForPermission(id);
await _permissionRepository.delete(id);
await fetch(page: _result?.page ?? 1);
if (groupId != null) {
await _syncPermissionsForGroup(groupId);
}
return true;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return false;
} finally {
@@ -198,9 +216,11 @@ class GroupPermissionController extends ChangeNotifier {
try {
final restored = await _permissionRepository.restore(id);
await fetch(page: _result?.page ?? 1);
await _syncPermissionsForGroup(restored.group.id);
return restored;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
@@ -219,4 +239,33 @@ class GroupPermissionController extends ChangeNotifier {
_isSubmitting = value;
notifyListeners();
}
Future<void> _syncPermissionsForGroup(int groupId) async {
final manager = _permissionManager;
if (manager == null) {
return;
}
try {
final synchronizer = PermissionSynchronizer(
repository: _permissionRepository,
manager: manager,
);
await synchronizer.syncForGroup(groupId);
} catch (_) {
// 권한 동기화 실패는 사용자 경험에 영향이 없도록 무시한다.
}
}
int? _resolveGroupIdForPermission(int permissionId) {
final current = _result?.items;
if (current == null) {
return null;
}
for (final item in current) {
if (item.id == permissionId) {
return item.group.id;
}
}
return null;
}
}

View File

@@ -9,6 +9,7 @@ import 'package:superport_v2/widgets/components/filter_bar.dart';
import 'package:superport_v2/widgets/components/superport_dialog.dart';
import '../../../../../core/config/environment.dart';
import '../../../../../core/permissions/permission_manager.dart';
import '../../../../../widgets/spec_page.dart';
import '../../../group/domain/entities/group.dart';
import '../../../group/domain/repositories/group_repository.dart';
@@ -118,14 +119,27 @@ class _GroupPermissionEnabledPageState
final intl.DateFormat _dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
String? _lastError;
bool _initialized = false;
@override
void initState() {
super.initState();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_initialized) {
return;
}
final permissionManager = PermissionScope.of(context);
_controller = GroupPermissionController(
permissionRepository: GetIt.I<GroupPermissionRepository>(),
groupRepository: GetIt.I<GroupRepository>(),
menuRepository: GetIt.I<MenuRepository>(),
permissionManager: permissionManager,
)..addListener(_handleControllerUpdate);
_initialized = true;
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _controller.loadGroups();
await _controller.loadMenus();

View File

@@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/core/network/api_routes.dart';
import '../../domain/entities/menu.dart';
import '../../domain/repositories/menu_repository.dart';
@@ -12,7 +13,7 @@ class MenuRepositoryRemote implements MenuRepository {
final ApiClient _api;
static const _basePath = '/menus';
static const _basePath = '${ApiRoutes.apiV1}/menus';
/// 메뉴 목록을 조회한다.
@override

View File

@@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/failure.dart';
import '../../domain/entities/menu.dart';
import '../../domain/repositories/menu_repository.dart';
@@ -50,8 +51,9 @@ class MenuController extends ChangeNotifier {
includeDeleted: false,
);
_parents = response.items;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoadingParents = false;
notifyListeners();
@@ -78,8 +80,9 @@ class MenuController extends ChangeNotifier {
includeDeleted: _includeDeleted,
);
_result = response;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoading = false;
notifyListeners();
@@ -118,8 +121,9 @@ class MenuController extends ChangeNotifier {
await fetch(page: 1);
await loadParents();
return created;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
@@ -135,8 +139,9 @@ class MenuController extends ChangeNotifier {
await fetch(page: _result?.page ?? 1);
await loadParents();
return updated;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
@@ -152,8 +157,9 @@ class MenuController extends ChangeNotifier {
await fetch(page: _result?.page ?? 1);
await loadParents();
return true;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return false;
} finally {
@@ -169,8 +175,9 @@ class MenuController extends ChangeNotifier {
await fetch(page: _result?.page ?? 1);
await loadParents();
return restored;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {

View File

@@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/core/network/api_routes.dart';
import '../../domain/entities/product.dart';
import '../../domain/repositories/product_repository.dart';
@@ -12,7 +13,7 @@ class ProductRepositoryRemote implements ProductRepository {
final ApiClient _api;
static const _basePath = '/products';
static const _basePath = '${ApiRoutes.apiV1}/products';
/// 제품 목록을 조회한다.
@override

View File

@@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/failure.dart';
import '../../../vendor/domain/entities/vendor.dart';
import '../../../vendor/domain/repositories/vendor_repository.dart';
@@ -77,8 +78,9 @@ class ProductController extends ChangeNotifier {
if (response.pageSize > 0 && response.pageSize != _pageSize) {
_pageSize = response.pageSize;
}
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoading = false;
notifyListeners();
@@ -94,8 +96,9 @@ class ProductController extends ChangeNotifier {
final uomResult = await _uomRepository.list(page: 1, pageSize: 100);
_vendorOptions = vendorResult.items;
_uomOptions = uomResult.items;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoadingLookups = false;
notifyListeners();
@@ -154,8 +157,9 @@ class ProductController extends ChangeNotifier {
final created = await _productRepository.create(input);
await fetch(page: 1);
return created;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
@@ -170,8 +174,9 @@ class ProductController extends ChangeNotifier {
final updated = await _productRepository.update(id, input);
await fetch(page: _result?.page ?? 1);
return updated;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
@@ -186,8 +191,9 @@ class ProductController extends ChangeNotifier {
await _productRepository.delete(id);
await fetch(page: _result?.page ?? 1);
return true;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return false;
} finally {
@@ -202,8 +208,9 @@ class ProductController extends ChangeNotifier {
final restored = await _productRepository.restore(id);
await fetch(page: _result?.page ?? 1);
return restored;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {

View File

@@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/core/network/api_routes.dart';
import '../../domain/entities/uom.dart';
import '../../domain/repositories/uom_repository.dart';
@@ -12,7 +13,7 @@ class UomRepositoryRemote implements UomRepository {
final ApiClient _api;
static const _basePath = '/uoms';
static const _basePath = '${ApiRoutes.apiV1}/uoms';
/// UOM 목록을 조회한다.
@override

View File

@@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/core/network/api_routes.dart';
import '../../domain/entities/user.dart';
import '../../domain/repositories/user_repository.dart';
@@ -12,7 +13,7 @@ class UserRepositoryRemote implements UserRepository {
final ApiClient _api;
static const _basePath = '/employees';
static const _basePath = '${ApiRoutes.apiV1}/employees';
/// 사용자 목록을 조회한다.
@override

View File

@@ -1,8 +1,12 @@
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/failure.dart';
import '../../../../../core/permissions/permission_manager.dart';
import '../../../group/domain/entities/group.dart';
import '../../../group/domain/repositories/group_repository.dart';
import '../../../group_permission/application/permission_synchronizer.dart';
import '../../../group_permission/domain/repositories/group_permission_repository.dart';
import '../../domain/entities/user.dart';
import '../../domain/repositories/user_repository.dart';
@@ -14,11 +18,17 @@ class UserController extends ChangeNotifier {
UserController({
required UserRepository userRepository,
required GroupRepository groupRepository,
GroupPermissionRepository? permissionRepository,
PermissionManager? permissionManager,
}) : _userRepository = userRepository,
_groupRepository = groupRepository;
_groupRepository = groupRepository,
_permissionRepository = permissionRepository,
_permissionManager = permissionManager;
final UserRepository _userRepository;
final GroupRepository _groupRepository;
final GroupPermissionRepository? _permissionRepository;
final PermissionManager? _permissionManager;
PaginatedResult<UserAccount>? _result;
bool _isLoading = false;
@@ -47,8 +57,9 @@ class UserController extends ChangeNotifier {
try {
final response = await _groupRepository.list(page: 1, pageSize: 100);
_groups = response.items;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoadingGroups = false;
notifyListeners();
@@ -74,8 +85,9 @@ class UserController extends ChangeNotifier {
isActive: isActive,
);
_result = response;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoading = false;
notifyListeners();
@@ -106,9 +118,11 @@ class UserController extends ChangeNotifier {
try {
final created = await _userRepository.create(input);
await fetch(page: 1);
await _syncPermissions(input.groupId);
return created;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
@@ -122,9 +136,11 @@ class UserController extends ChangeNotifier {
try {
final updated = await _userRepository.update(id, input);
await fetch(page: _result?.page ?? 1);
await _syncPermissions(input.groupId);
return updated;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
@@ -136,11 +152,16 @@ class UserController extends ChangeNotifier {
Future<bool> delete(int id) async {
_setSubmitting(true);
try {
final groupId = _resolveGroupId(id);
await _userRepository.delete(id);
await fetch(page: _result?.page ?? 1);
if (groupId != null) {
await _syncPermissions(groupId);
}
return true;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return false;
} finally {
@@ -154,9 +175,14 @@ class UserController extends ChangeNotifier {
try {
final restored = await _userRepository.restore(id);
await fetch(page: _result?.page ?? 1);
final groupId = restored.group?.id;
if (groupId != null) {
await _syncPermissions(groupId);
}
return restored;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
@@ -175,4 +201,34 @@ class UserController extends ChangeNotifier {
_isSubmitting = value;
notifyListeners();
}
Future<void> _syncPermissions(int groupId) async {
final manager = _permissionManager;
final repository = _permissionRepository;
if (manager == null || repository == null) {
return;
}
try {
final synchronizer = PermissionSynchronizer(
repository: repository,
manager: manager,
);
await synchronizer.syncForGroup(groupId);
} catch (_) {
// 권한 동기화 실패는 무시하고 기존 흐름을 유지한다.
}
}
int? _resolveGroupId(int userId) {
final items = _result?.items;
if (items == null) {
return null;
}
for (final user in items) {
if (user.id == userId) {
return user.group?.id;
}
}
return null;
}
}

View File

@@ -8,9 +8,11 @@ import 'package:superport_v2/widgets/components/filter_bar.dart';
import 'package:superport_v2/widgets/components/superport_dialog.dart';
import '../../../../../core/config/environment.dart';
import '../../../../../core/permissions/permission_manager.dart';
import '../../../../../widgets/spec_page.dart';
import '../../../group/domain/entities/group.dart';
import '../../../group/domain/repositories/group_repository.dart';
import '../../../group_permission/domain/repositories/group_permission_repository.dart';
import '../../domain/entities/user.dart';
import '../../domain/repositories/user_repository.dart';
import '../controllers/user_controller.dart';
@@ -96,17 +98,31 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
final FocusNode _searchFocus = FocusNode();
bool _groupsLoaded = false;
String? _lastError;
bool _initialized = false;
@override
void initState() {
super.initState();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_initialized) {
return;
}
final permissionManager = PermissionScope.of(context);
_controller = UserController(
userRepository: GetIt.I<UserRepository>(),
groupRepository: GetIt.I<GroupRepository>(),
permissionRepository: GetIt.I<GroupPermissionRepository>(),
permissionManager: permissionManager,
)..addListener(_handleControllerUpdate);
_initialized = true;
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _controller.loadGroups();
await _controller.fetch();
if (!mounted) return;
setState(() {
_groupsLoaded = true;
});

View File

@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/core/network/api_routes.dart';
import '../../domain/entities/vendor.dart';
import '../../domain/repositories/vendor_repository.dart';
@@ -13,7 +14,7 @@ class VendorRepositoryRemote implements VendorRepository {
final ApiClient _api;
static const _basePath = '/vendors'; // TODO: 백엔드 경로 확정 시 수정
static const _basePath = '${ApiRoutes.apiV1}/vendors';
@override
Future<PaginatedResult<Vendor>> list({

View File

@@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/failure.dart';
import '../../domain/entities/vendor.dart';
import '../../domain/repositories/vendor_repository.dart';
@@ -55,8 +56,9 @@ class VendorController extends ChangeNotifier {
if (response.pageSize > 0 && response.pageSize != _pageSize) {
_pageSize = response.pageSize;
}
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoading = false;
notifyListeners();
@@ -94,8 +96,9 @@ class VendorController extends ChangeNotifier {
final vendor = await _repository.create(input);
await fetch(page: 1);
return vendor;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
@@ -110,8 +113,9 @@ class VendorController extends ChangeNotifier {
final vendor = await _repository.update(id, input);
await fetch(page: _result?.page ?? 1);
return vendor;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
@@ -126,8 +130,9 @@ class VendorController extends ChangeNotifier {
await _repository.delete(id);
await fetch(page: _result?.page ?? 1);
return true;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return false;
} finally {
@@ -142,8 +147,9 @@ class VendorController extends ChangeNotifier {
final vendor = await _repository.restore(id);
await fetch(page: _result?.page ?? 1);
return vendor;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {

View File

@@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/core/network/api_routes.dart';
import '../../domain/entities/warehouse.dart';
import '../../domain/repositories/warehouse_repository.dart';
@@ -12,7 +13,7 @@ class WarehouseRepositoryRemote implements WarehouseRepository {
final ApiClient _api;
static const _basePath = '/warehouses';
static const _basePath = '${ApiRoutes.apiV1}/warehouses';
/// 창고 목록을 조회한다.
@override
@@ -21,6 +22,7 @@ class WarehouseRepositoryRemote implements WarehouseRepository {
int pageSize = 20,
String? query,
bool? isActive,
bool includeZipcode = true,
}) async {
final response = await _api.get<Map<String, dynamic>>(
_basePath,
@@ -29,6 +31,7 @@ class WarehouseRepositoryRemote implements WarehouseRepository {
'page_size': pageSize,
if (query != null && query.isNotEmpty) 'q': query,
if (isActive != null) 'is_active': isActive,
if (includeZipcode) 'include': 'zipcode',
},
options: Options(responseType: ResponseType.json),
);

View File

@@ -10,6 +10,7 @@ abstract class WarehouseRepository {
int pageSize = 20,
String? query,
bool? isActive,
bool includeZipcode = true,
});
/// 창고를 생성한다.

View File

@@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/failure.dart';
import '../../domain/entities/warehouse.dart';
import '../../domain/repositories/warehouse_repository.dart';
@@ -53,8 +54,9 @@ class WarehouseController extends ChangeNotifier {
if (response.pageSize > 0 && response.pageSize != _pageSize) {
_pageSize = response.pageSize;
}
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoading = false;
notifyListeners();
@@ -95,8 +97,9 @@ class WarehouseController extends ChangeNotifier {
final created = await _repository.create(input);
await fetch(page: 1);
return created;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
@@ -111,8 +114,9 @@ class WarehouseController extends ChangeNotifier {
final updated = await _repository.update(id, input);
await fetch(page: _result?.page ?? 1);
return updated;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
@@ -127,8 +131,9 @@ class WarehouseController extends ChangeNotifier {
await _repository.delete(id);
await fetch(page: _result?.page ?? 1);
return true;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return false;
} finally {
@@ -143,8 +148,9 @@ class WarehouseController extends ChangeNotifier {
final restored = await _repository.restore(id);
await fetch(page: _result?.page ?? 1);
return restored;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {

View File

@@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/core/network/api_routes.dart';
import '../../domain/entities/postal_code.dart';
import '../../domain/repositories/postal_search_repository.dart';
@@ -13,12 +14,13 @@ class PostalSearchRepositoryRemote implements PostalSearchRepository {
final ApiClient _api;
static const _path = '/zipcodes';
static const _path = '${ApiRoutes.apiV1}/zipcodes';
@override
Future<List<PostalCode>> search({
required String keyword,
int limit = 20,
int page = 1,
}) async {
final trimmed = keyword.trim();
if (trimmed.isEmpty) {
@@ -27,12 +29,7 @@ class PostalSearchRepositoryRemote implements PostalSearchRepository {
final response = await _api.get<dynamic>(
_path,
query: {
'zipcode': trimmed,
'road_name': trimmed,
'q': trimmed,
'page_size': limit,
},
query: {'q': trimmed, 'page': page, 'page_size': limit},
options: Options(responseType: ResponseType.json),
);

View File

@@ -5,6 +5,11 @@ abstract class PostalSearchRepository {
/// 키워드를 기반으로 우편번호 목록을 검색한다.
///
/// [keyword]는 우편번호/도로명/건물번호 중 하나의 문자열을 전달한다.
/// [limit]을 지정하면 최대 반환 건수를 제한한다.
Future<List<PostalCode>> search({required String keyword, int limit = 20});
/// [limit]은 페이지 크기(`page_size`)에 대응한다.
/// [page]를 지정하면 서버 페이지네이션을 제어한다.
Future<List<PostalCode>> search({
required String keyword,
int limit = 20,
int page = 1,
});
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/core/network/failure.dart';
import 'package:superport_v2/widgets/components/superport_table.dart';
import '../../domain/entities/postal_code.dart';
@@ -102,7 +103,8 @@ class _PostalSearchDialogState extends State<_PostalSearchDialog> {
if (!mounted) return;
setState(() {
_results = const [];
_errorMessage = error.toString();
final failure = Failure.from(error);
_errorMessage = failure.describe();
});
} finally {
if (mounted) {