feat(frontend): 승인 템플릿 API 통합 및 디버그 로그인 확장

- docs 폴더 문서를 최신 API 계약으로 갱신하고 가이드를 다듬었다\n- approvals data/presentation 레이어를 API v4 스펙에 맞춰 리팩터링했다\n- approver 자동완성 위젯을 신규 공유 레포지토리에 맞춰 교체하고 UX를 보강했다\n- inventory/rental 페이지 테이블 초기화 시 승인 기준 연동을 정비했다\n- 로그인 페이지 디버그 버튼을 tera/exa 계정으로 분리해 QA 로그인을 단순화했다\n- get_it 등록과 테스트 케이스를 신규 공유 리포지토리에 맞춰 업데이트했다
This commit is contained in:
JiWoong Sul
2025-11-05 17:05:38 +09:00
parent 3e83408aa7
commit fa0bda5ea4
28 changed files with 1102 additions and 545 deletions

View File

@@ -17,7 +17,10 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
final ApiClient _api;
static const _basePath = '${ApiRoutes.apiV1}/approval-templates';
static const _basePaths = <String>[
ApiRoutes.approvalTemplates,
ApiRoutes.approvalTemplatesLegacy,
];
/// 결재 템플릿 목록을 조회한다. 검색/활성 여부 필터를 지원한다.
@override
@@ -27,17 +30,19 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
String? query,
bool? isActive,
}) async {
final response = await _api.get<Map<String, dynamic>>(
_basePath,
query: {
'page': page,
'page_size': pageSize,
if (query != null && query.isNotEmpty) 'q': query,
if (isActive != null) 'active': isActive,
},
options: Options(responseType: ResponseType.json),
);
return ApprovalTemplateDto.parsePaginated(response.data);
return _withTemplateRoute((basePath) async {
final response = await _api.get<Map<String, dynamic>>(
basePath,
query: {
'page': page,
'page_size': pageSize,
if (query != null && query.isNotEmpty) 'q': query,
if (isActive != null) 'active': isActive,
},
options: Options(responseType: ResponseType.json),
);
return ApprovalTemplateDto.parsePaginated(response.data);
});
}
/// 템플릿 상세 정보를 조회한다. 필요 시 단계 포함 여부를 지정한다.
@@ -46,14 +51,17 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
int id, {
bool includeSteps = true,
}) async {
final response = await _api.get<Map<String, dynamic>>(
'$_basePath/$id',
query: {if (includeSteps) 'include': 'steps'},
options: Options(responseType: ResponseType.json),
);
return ApprovalTemplateDto.fromJson(
_api.unwrapAsMap(response),
).toEntity(includeSteps: includeSteps);
return _withTemplateRoute((basePath) async {
final path = ApiClient.buildPath(basePath, [id]);
final response = await _api.get<Map<String, dynamic>>(
path,
query: {if (includeSteps) 'include': 'steps'},
options: Options(responseType: ResponseType.json),
);
return ApprovalTemplateDto.fromJson(
_api.unwrapAsMap(response),
).toEntity(includeSteps: includeSteps);
});
}
/// 템플릿을 생성하고 필요하면 단계까지 함께 등록한다.
@@ -62,18 +70,20 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
ApprovalTemplateInput input, {
List<ApprovalTemplateStepInput> steps = const [],
}) async {
final response = await _api.post<Map<String, dynamic>>(
_basePath,
data: input.toCreatePayload(),
options: Options(responseType: ResponseType.json),
);
final created = ApprovalTemplateDto.fromJson(
_api.unwrapAsMap(response),
).toEntity(includeSteps: false);
if (steps.isNotEmpty) {
await _postSteps(created.id, steps);
}
return fetchDetail(created.id, includeSteps: true);
return _withTemplateRoute((basePath) async {
final response = await _api.post<Map<String, dynamic>>(
basePath,
data: input.toCreatePayload(),
options: Options(responseType: ResponseType.json),
);
final created = ApprovalTemplateDto.fromJson(
_api.unwrapAsMap(response),
).toEntity(includeSteps: false);
if (steps.isNotEmpty) {
await _postSteps(created.id, steps, basePath: basePath);
}
return fetchDetail(created.id, includeSteps: true);
});
}
/// 템플릿 기본 정보와 단계 구성을 수정한다.
@@ -83,43 +93,54 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
ApprovalTemplateInput input, {
List<ApprovalTemplateStepInput>? steps,
}) async {
await _api.patch<Map<String, dynamic>>(
'$_basePath/$id',
data: input.toUpdatePayload(id),
options: Options(responseType: ResponseType.json),
);
if (steps != null) {
await _patchSteps(id, steps);
}
return fetchDetail(id, includeSteps: true);
return _withTemplateRoute((basePath) async {
final path = ApiClient.buildPath(basePath, [id]);
await _api.patch<Map<String, dynamic>>(
path,
data: input.toUpdatePayload(id),
options: Options(responseType: ResponseType.json),
);
if (steps != null) {
await _patchSteps(id, steps, basePath: basePath);
}
return fetchDetail(id, includeSteps: true);
});
}
/// 템플릿을 삭제한다.
@override
Future<void> delete(int id) async {
await _api.delete<void>('$_basePath/$id');
await _withTemplateRoute((basePath) async {
final path = ApiClient.buildPath(basePath, [id]);
await _api.delete<void>(path);
});
}
/// 삭제된 템플릿을 복구한다.
@override
Future<ApprovalTemplate> restore(int id) async {
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/$id/restore',
options: Options(responseType: ResponseType.json),
);
return ApprovalTemplateDto.fromJson(
_api.unwrapAsMap(response),
).toEntity(includeSteps: false);
return _withTemplateRoute((basePath) async {
final path = ApiClient.buildPath(basePath, [id, 'restore']);
final response = await _api.post<Map<String, dynamic>>(
path,
options: Options(responseType: ResponseType.json),
);
return ApprovalTemplateDto.fromJson(
_api.unwrapAsMap(response),
).toEntity(includeSteps: false);
});
}
/// 템플릿 단계 전체를 신규로 등록한다.
Future<void> _postSteps(
int templateId,
List<ApprovalTemplateStepInput> steps,
) async {
List<ApprovalTemplateStepInput> steps, {
required String basePath,
}) async {
if (steps.isEmpty) return;
final path = ApiClient.buildPath(basePath, [templateId, 'steps']);
await _api.post<Map<String, dynamic>>(
'$_basePath/$templateId/steps',
path,
data: {
'id': templateId,
'steps': steps.map((step) => step.toJson(includeId: false)).toList(),
@@ -131,10 +152,12 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
/// 템플릿 단계 정보를 부분 수정한다.
Future<void> _patchSteps(
int templateId,
List<ApprovalTemplateStepInput> steps,
) async {
List<ApprovalTemplateStepInput> steps, {
required String basePath,
}) async {
final path = ApiClient.buildPath(basePath, [templateId, 'steps']);
await _api.patch<Map<String, dynamic>>(
'$_basePath/$templateId/steps',
path,
data: {
'id': templateId,
'steps': steps.map((step) => step.toJson()).toList(),
@@ -142,4 +165,25 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
options: Options(responseType: ResponseType.json),
);
}
Future<T> _withTemplateRoute<T>(
Future<T> Function(String basePath) operation,
) async {
DioException? lastNotFound;
for (final basePath in _basePaths) {
try {
return await operation(basePath);
} on DioException catch (error) {
if (error.response?.statusCode == 404) {
lastNotFound = error;
continue;
}
rethrow;
}
}
if (lastNotFound != null) {
throw lastNotFound;
}
throw StateError('템플릿 경로 후보가 정의되지 않았습니다.');
}
}

View File

@@ -22,9 +22,9 @@ import '../../../domain/entities/approval_flow.dart';
import '../../../domain/repositories/approval_repository.dart';
import '../../../domain/usecases/recall_approval_use_case.dart';
import '../../../domain/usecases/resubmit_approval_use_case.dart';
import '../../../shared/domain/entities/approval_approver_candidate.dart';
import '../../../shared/widgets/widgets.dart';
import '../../../shared/widgets/approver_autocomplete_field.dart';
import '../../../shared/approver_catalog.dart';
import '../controllers/approval_history_controller.dart';
import '../widgets/approval_audit_log_table.dart';
import '../widgets/approval_flow_timeline.dart';
@@ -315,7 +315,7 @@ class _ApprovalHistoryEnabledPageState
_refreshAuditForSelectedRecord(resetPage: true);
}
void _handleAuditActorSelected(ApprovalApproverCatalogItem? item) {
void _handleAuditActorSelected(ApprovalApproverCandidate? item) {
final selectedId = item?.id ?? int.tryParse(_auditActorIdController.text);
_controller.updateAuditActor(selectedId);
_refreshAuditForSelectedRecord(resetPage: true);

View File

@@ -1,5 +1,9 @@
import 'dart:async';
import 'package:get_it/get_it.dart';
import '../../../domain/entities/approval.dart';
import '../../../shared/approver_catalog.dart';
import '../../../shared/domain/repositories/approval_approver_repository.dart';
import '../controllers/approval_request_controller.dart';
import '../../../../inventory/transactions/domain/entities/stock_transaction_input.dart';
@@ -10,12 +14,13 @@ class ApprovalFormInitializer {
ApprovalFormInitializer._();
/// 결재 구성 컨트롤러에 기본값을 주입한다.
static void populate({
static Future<void> populate({
required ApprovalRequestController controller,
Approval? existingApproval,
StockTransactionApprovalInput? draft,
ApprovalRequestParticipant? defaultRequester,
}) {
ApprovalApproverRepository? repository,
}) async {
if (existingApproval != null) {
_applyExistingApproval(controller, existingApproval);
return;
@@ -24,7 +29,11 @@ class ApprovalFormInitializer {
controller.setRequester(defaultRequester);
}
if (draft != null) {
_applyDraft(controller, draft);
await _applyDraft(
controller,
draft,
repository ?? _resolveRepository(),
);
}
}
@@ -57,40 +66,62 @@ class ApprovalFormInitializer {
}
}
static void _applyDraft(
static Future<void> _applyDraft(
ApprovalRequestController controller,
StockTransactionApprovalInput draft,
) {
final requesterCatalog = ApprovalApproverCatalog.byId(draft.requestedById);
if (requesterCatalog != null) {
controller.setRequester(
ApprovalRequestParticipant(
id: requesterCatalog.id,
name: requesterCatalog.name,
employeeNo: requesterCatalog.employeeNo,
),
);
ApprovalApproverRepository? repository,
) async {
final repo = repository;
if (repo == null) {
return;
}
final steps = draft.steps
.map((step) {
final catalog = ApprovalApproverCatalog.byId(step.approverId);
if (catalog == null) {
final requester = await _fetchParticipant(repo, draft.requestedById);
if (requester != null) {
controller.setRequester(requester);
}
final futures = draft.steps
.map((step) async {
final participant = await _fetchParticipant(repo, step.approverId);
if (participant == null) {
return null;
}
return ApprovalRequestStep(
stepOrder: step.stepOrder,
approver: ApprovalRequestParticipant(
id: catalog.id,
name: catalog.name,
employeeNo: catalog.employeeNo,
),
approver: participant,
note: step.note,
);
})
.whereType<ApprovalRequestStep>()
.toList(growable: false);
final resolvedSteps = await Future.wait(futures);
final steps = resolvedSteps.whereType<ApprovalRequestStep>().toList();
if (steps.isNotEmpty) {
controller.applyTemplateSteps(steps);
}
}
static Future<ApprovalRequestParticipant?> _fetchParticipant(
ApprovalApproverRepository repository,
int id,
) async {
final candidate = await repository.fetchById(id);
if (candidate == null) {
return null;
}
return ApprovalRequestParticipant(
id: candidate.id,
name: candidate.name,
employeeNo: candidate.employeeNo,
);
}
static ApprovalApproverRepository? _resolveRepository() {
final getIt = GetIt.I;
if (!getIt.isRegistered<ApprovalApproverRepository>()) {
return null;
}
return getIt<ApprovalApproverRepository>();
}
}

View File

@@ -4,7 +4,7 @@ import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../../widgets/components/feedback.dart';
import '../../../../../widgets/components/superport_dialog.dart';
import '../../../shared/approver_catalog.dart';
import '../../../shared/domain/entities/approval_approver_candidate.dart';
import '../../../shared/widgets/approver_autocomplete_field.dart';
import '../controllers/approval_request_controller.dart';
import 'approval_step_row.dart';
@@ -435,7 +435,7 @@ class _ConfiguratorDialogBodyState extends State<_ConfiguratorDialogBody> {
}
Future<void> _openAddStepDialog() async {
ApprovalApproverCatalogItem? selected;
ApprovalApproverCandidate? selected;
final idController = TextEditingController();
final result = await SuperportDialog.show<bool>(
@@ -483,13 +483,19 @@ class _ConfiguratorDialogBodyState extends State<_ConfiguratorDialogBody> {
return;
}
final participant = _resolveParticipant(selected, idController.text.trim());
if (participant == null) {
final selection = selected;
if (selection == null) {
SuperportToast.warning(context, '유효한 승인자를 선택해주세요.');
idController.dispose();
return;
}
final participant = ApprovalRequestParticipant(
id: selection.id,
name: selection.name,
employeeNo: selection.employeeNo,
);
final added = widget.controller.addStep(approver: participant);
if (!added) {
final message = widget.controller.errorMessage ?? '결재 단계를 추가하지 못했습니다.';
@@ -503,31 +509,6 @@ class _ConfiguratorDialogBodyState extends State<_ConfiguratorDialogBody> {
idController.dispose();
}
ApprovalRequestParticipant? _resolveParticipant(
ApprovalApproverCatalogItem? selected,
String manualInput,
) {
if (selected != null) {
return ApprovalRequestParticipant(
id: selected.id,
name: selected.name,
employeeNo: selected.employeeNo,
);
}
final manualId = int.tryParse(manualInput);
if (manualId == null) {
return null;
}
final match = ApprovalApproverCatalog.byId(manualId);
if (match == null) {
return null;
}
return ApprovalRequestParticipant(
id: match.id,
name: match.name,
employeeNo: match.employeeNo,
);
}
}
class _InfoBadge extends StatelessWidget {

View File

@@ -3,7 +3,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../../widgets/components/feedback.dart';
import '../../../shared/approver_catalog.dart';
import '../../../shared/domain/entities/approval_approver_candidate.dart';
import '../../../shared/widgets/approver_autocomplete_field.dart';
import '../controllers/approval_request_controller.dart';
@@ -96,37 +96,23 @@ class _ApprovalStepRowState extends State<ApprovalStepRow> {
Future<void> _handleApproverSelected(
BuildContext context,
ApprovalApproverCatalogItem? item,
ApprovalApproverCandidate? candidate,
) async {
if (widget.readOnly) {
return;
}
ApprovalRequestParticipant? nextParticipant;
if (item != null) {
if (candidate != null) {
nextParticipant = ApprovalRequestParticipant(
id: item.id,
name: item.name,
employeeNo: item.employeeNo,
id: candidate.id,
name: candidate.name,
employeeNo: candidate.employeeNo,
);
} else {
final manualId = int.tryParse(_approverIdController.text.trim());
if (manualId == null) {
SuperportToast.warning(context, '승인자를 다시 선택해주세요.');
_restorePreviousApprover();
return;
}
final catalogMatch = ApprovalApproverCatalog.byId(manualId);
if (catalogMatch == null) {
SuperportToast.warning(context, '등록되지 않은 승인자입니다.');
_restorePreviousApprover();
return;
}
nextParticipant = ApprovalRequestParticipant(
id: catalogMatch.id,
name: catalogMatch.name,
employeeNo: catalogMatch.employeeNo,
);
SuperportToast.warning(context, '승인자를 다시 선택해주세요.');
_restorePreviousApprover();
return;
}
final updated = widget.controller.updateStep(

View File

@@ -1,148 +0,0 @@
import 'package:flutter/material.dart';
/// 결재 승인자(approver)를 자동완성으로 검색하기 위한 카탈로그 항목.
class ApprovalApproverCatalogItem {
const ApprovalApproverCatalogItem({
required this.id,
required this.employeeNo,
required this.name,
required this.team,
});
final int id;
final String employeeNo;
final String name;
final String team;
}
String _normalize(String value) {
return value.toLowerCase().replaceAll(RegExp(r'[^a-z0-9가-힣]'), '');
}
/// 결재용 승인자 카탈로그.
///
/// - API 연동 전까지 고정된 데이터를 사용한다.
class ApprovalApproverCatalog {
static final List<ApprovalApproverCatalogItem> items = List.unmodifiable([
const ApprovalApproverCatalogItem(
id: 101,
employeeNo: 'EMP101',
name: '이검토',
team: '물류운영팀',
),
const ApprovalApproverCatalogItem(
id: 102,
employeeNo: 'EMP102',
name: '최검수',
team: '품질보증팀',
),
const ApprovalApproverCatalogItem(
id: 103,
employeeNo: 'EMP103',
name: '문회수',
team: '품질보증팀',
),
const ApprovalApproverCatalogItem(
id: 104,
employeeNo: 'EMP104',
name: '박팀장',
team: '운영혁신팀',
),
const ApprovalApproverCatalogItem(
id: 105,
employeeNo: 'EMP105',
name: '정차장',
team: '구매팀',
),
const ApprovalApproverCatalogItem(
id: 106,
employeeNo: 'EMP106',
name: '오승훈',
team: '영업지원팀',
),
const ApprovalApproverCatalogItem(
id: 107,
employeeNo: 'EMP107',
name: '유컨펌',
team: '총무팀',
),
const ApprovalApproverCatalogItem(
id: 108,
employeeNo: 'EMP108',
name: '문서결',
team: '경영기획팀',
),
const ApprovalApproverCatalogItem(
id: 110,
employeeNo: 'EMP110',
name: '문검토',
team: '물류운영팀',
),
const ApprovalApproverCatalogItem(
id: 120,
employeeNo: 'EMP120',
name: '신품질',
team: '품질관리팀',
),
const ApprovalApproverCatalogItem(
id: 201,
employeeNo: 'EMP201',
name: '한임원',
team: '경영진',
),
const ApprovalApproverCatalogItem(
id: 210,
employeeNo: 'EMP210',
name: '강팀장',
team: '물류운영팀',
),
const ApprovalApproverCatalogItem(
id: 221,
employeeNo: 'EMP221',
name: '노부장',
team: '경영관리팀',
),
]);
static final Map<int, ApprovalApproverCatalogItem> _byId = {
for (final item in items) item.id: item,
};
static final Map<String, ApprovalApproverCatalogItem> _byEmployeeNo = {
for (final item in items) item.employeeNo.toLowerCase(): item,
};
static ApprovalApproverCatalogItem? byId(int? id) =>
id == null ? null : _byId[id];
static ApprovalApproverCatalogItem? byEmployeeNo(String? employeeNo) {
if (employeeNo == null) return null;
return _byEmployeeNo[employeeNo.toLowerCase()];
}
static List<ApprovalApproverCatalogItem> filter(String query) {
final normalized = _normalize(query);
if (normalized.isEmpty) {
return items.take(10).toList();
}
final lower = query.toLowerCase();
return [
for (final item in items)
if (_normalize(item.name).contains(normalized) ||
item.employeeNo.toLowerCase().contains(lower) ||
item.team.toLowerCase().contains(lower) ||
item.id.toString().contains(lower))
item,
];
}
}
/// 자동완성 추천이 없을 때 보여줄 위젯.
Widget buildEmptyApproverResult(TextTheme textTheme) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Text('일치하는 승인자를 찾지 못했습니다.', style: textTheme.bodySmall),
),
);
}

View File

@@ -0,0 +1,53 @@
import 'package:superport_v2/core/common/utils/json_utils.dart';
import '../../domain/entities/approval_approver_candidate.dart';
/// 승인자 후보 응답을 파싱하는 DTO.
class ApprovalApproverCandidateDto {
ApprovalApproverCandidateDto({
required this.id,
required this.employeeNo,
required this.name,
this.team,
this.email,
this.phone,
});
final int id;
final String employeeNo;
final String name;
final String? team;
final String? email;
final String? phone;
/// JSON 응답에서 DTO를 생성한다.
factory ApprovalApproverCandidateDto.fromJson(Map<String, dynamic> json) {
final group = json['group'] is Map<String, dynamic>
? json['group'] as Map<String, dynamic>
: null;
return ApprovalApproverCandidateDto(
id: json['id'] as int? ?? JsonUtils.readInt(json, 'user_id', fallback: 0),
employeeNo: json['employee_id'] as String? ??
json['employee_no'] as String? ??
'-',
name: json['name'] as String? ??
json['employee_name'] as String? ??
'-',
team: group?['group_name'] as String? ?? json['team'] as String?,
email: json['email'] as String?,
phone: json['phone'] as String? ?? json['mobile_no'] as String?,
);
}
/// DTO를 도메인 엔티티로 변환한다.
ApprovalApproverCandidate toEntity() {
return ApprovalApproverCandidate(
id: id,
employeeNo: employeeNo,
name: name,
team: team,
email: email,
phone: phone,
);
}
}

View File

@@ -0,0 +1,84 @@
import 'package:dio/dio.dart';
import '../../../../../core/network/api_client.dart';
import '../../../../../core/network/api_routes.dart';
import '../../domain/entities/approval_approver_candidate.dart';
import '../../domain/repositories/approval_approver_repository.dart';
import '../dtos/approval_approver_candidate_dto.dart';
/// 승인자 자동완성용 원격 저장소 구현체.
class ApprovalApproverRepositoryRemote implements ApprovalApproverRepository {
ApprovalApproverRepositoryRemote({required ApiClient apiClient})
: _api = apiClient;
final ApiClient _api;
static const _basePath = '${ApiRoutes.apiV1}/users';
@override
Future<List<ApprovalApproverCandidate>> search({
required String keyword,
int limit = 20,
}) async {
final trimmed = keyword.trim();
if (trimmed.isEmpty) {
return const [];
}
final response = await _api.get<Map<String, dynamic>>(
_basePath,
query: _buildQuery(limit: limit, keyword: trimmed),
options: Options(responseType: ResponseType.json),
);
return _mapCandidates(response.data);
}
@override
Future<ApprovalApproverCandidate?> fetchById(int id) async {
final response = await _api.get<Map<String, dynamic>>(
'$_basePath/$id',
query: ApiClient.buildQuery(include: const ['group']),
options: Options(responseType: ResponseType.json),
);
final payload = _api.unwrapAsMap(response);
if (payload.isEmpty) {
return null;
}
return ApprovalApproverCandidateDto.fromJson(payload).toEntity();
}
@override
Future<List<ApprovalApproverCandidate>> listInitial({int limit = 20}) async {
final response = await _api.get<Map<String, dynamic>>(
_basePath,
query: _buildQuery(limit: limit),
options: Options(responseType: ResponseType.json),
);
return _mapCandidates(response.data);
}
Map<String, dynamic> _buildQuery({required int limit, String? keyword}) {
return ApiClient.buildQuery(
page: 1,
pageSize: limit,
q: keyword,
sort: 'name',
order: 'asc',
include: const ['group'],
filters: const {'is_active': true},
);
}
List<ApprovalApproverCandidate> _mapCandidates(
Map<String, dynamic>? payload,
) {
return (payload?['items'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(ApprovalApproverCandidateDto.fromJson)
.map((dto) => dto.toEntity())
.toList(growable: false);
}
}

View File

@@ -0,0 +1,32 @@
/// 결재 승인자 자동완성에 사용되는 후보 정보.
class ApprovalApproverCandidate {
const ApprovalApproverCandidate({
required this.id,
required this.employeeNo,
required this.name,
this.team,
this.email,
this.phone,
});
/// 승인자 고유 ID (users.id).
final int id;
/// 사번 혹은 직원 식별자.
final String employeeNo;
/// 직원 이름.
final String name;
/// 소속 팀 또는 그룹명.
final String? team;
/// 이메일 주소.
final String? email;
/// 전화번호.
final String? phone;
/// 리스트 등에서 표시할 기본 라벨을 반환한다.
String get displayLabel => '$name ($employeeNo)';
}

View File

@@ -0,0 +1,16 @@
import '../entities/approval_approver_candidate.dart';
/// 승인자 검색을 제공하는 저장소 인터페이스.
abstract class ApprovalApproverRepository {
/// 키워드로 승인자 후보를 검색한다.
Future<List<ApprovalApproverCandidate>> search({
required String keyword,
int limit = 20,
});
/// ID로 승인자 정보를 조회한다.
Future<ApprovalApproverCandidate?> fetchById(int id);
/// 자동완성 드롭다운 초기 노출용 활성 승인자 목록을 조회한다.
Future<List<ApprovalApproverCandidate>> listInitial({int limit = 20});
}

View File

@@ -1,12 +1,16 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../approver_catalog.dart';
import '../domain/entities/approval_approver_candidate.dart';
import '../domain/repositories/approval_approver_repository.dart';
/// 승인자 자동완성 필드.
///
/// - 사용자가 이름/사번으로 검색하면 일치하는 승인자를 제안한다.
/// - 항목 선택하면 `idController`에 승인자 ID가 채워진다.
/// - 이름/사번을 입력하면 API에서 승인자 후보를 검색한다.
/// - 항목 선택 `idController`에 승인자 ID를 기록한다.
class ApprovalApproverAutocompleteField extends StatefulWidget {
const ApprovalApproverAutocompleteField({
super.key,
@@ -17,104 +21,309 @@ class ApprovalApproverAutocompleteField extends StatefulWidget {
final TextEditingController idController;
final String? hintText;
final void Function(ApprovalApproverCatalogItem?)? onSelected;
final void Function(ApprovalApproverCandidate?)? onSelected;
@override
State<ApprovalApproverAutocompleteField> createState() =>
_ApprovalApproverAutocompleteFieldState();
}
/// 승인자 자동완성 필드의 내부 상태를 관리한다.
class _ApprovalApproverAutocompleteFieldState
extends State<ApprovalApproverAutocompleteField> {
late final TextEditingController _textController;
late final FocusNode _focusNode;
ApprovalApproverCatalogItem? _selected;
static const _debounceDuration = Duration(milliseconds: 250);
static const _pageSize = 15;
final TextEditingController _textController = TextEditingController();
final FocusNode _focusNode = FocusNode();
final List<ApprovalApproverCandidate> _suggestions = [];
final List<ApprovalApproverCandidate> _initialSuggestions = [];
ApprovalApproverCandidate? _selected;
Timer? _debounce;
bool _isSearching = false;
bool _isLoadingInitial = false;
bool _initialLoaded = false;
bool _isApplyingText = false;
int _requestId = 0;
ApprovalApproverRepository? get _repository =>
GetIt.I.isRegistered<ApprovalApproverRepository>()
? GetIt.I<ApprovalApproverRepository>()
: null;
@override
void initState() {
super.initState();
_textController = TextEditingController();
_focusNode = FocusNode();
_focusNode.addListener(_handleFocusChange);
_syncFromId();
_initializeFromId();
unawaited(_prefetchInitialCandidates());
}
/// 외부에서 제공된 ID 값으로부터 표시 문자열을 동기화한다.
void _syncFromId() {
final idText = widget.idController.text.trim();
final id = int.tryParse(idText);
final match = ApprovalApproverCatalog.byId(id);
if (match != null) {
_selected = match;
_textController.text = _displayLabel(match);
} else if (id != null) {
_selected = null;
_textController.text = '직접 입력: $id';
} else {
_selected = null;
_textController.clear();
@override
void didUpdateWidget(covariant ApprovalApproverAutocompleteField oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(widget.idController, oldWidget.idController)) {
_initializeFromId();
}
}
/// 검색어에 매칭되는 승인자 목록을 반환한다.
Iterable<ApprovalApproverCatalogItem> _options(String query) {
return ApprovalApproverCatalog.filter(query);
}
/// 특정 승인자를 선택했을 때 내부 상태와 콜백을 갱신한다.
void _handleSelected(ApprovalApproverCatalogItem item) {
setState(() {
_selected = item;
widget.idController.text = item.id.toString();
_textController.text = _displayLabel(item);
widget.onSelected?.call(item);
});
}
/// 선택된 값을 초기화한다.
void _handleCleared() {
setState(() {
_selected = null;
widget.idController.clear();
_textController.clear();
Future<void> _initializeFromId() async {
final idText = widget.idController.text.trim();
final id = int.tryParse(idText);
if (id == null) {
setState(() {
_selected = null;
_setText('');
});
return;
}
final repository = _repository;
if (repository == null) {
return;
}
try {
final candidate = await repository.fetchById(id);
if (!mounted) {
return;
}
if (candidate == null) {
setState(() {
_selected = null;
_setText('');
});
widget.onSelected?.call(null);
return;
}
setState(() {
_selected = candidate;
});
_ensureCandidateCached(candidate);
_setText(_displayLabel(candidate));
widget.onSelected?.call(candidate);
} catch (_) {
// 조회 실패 시 기존 상태를 유지하되 텍스트를 비운다.
if (!mounted) {
return;
}
setState(() {
_selected = null;
_setText('');
});
widget.onSelected?.call(null);
}
}
Future<void> _prefetchInitialCandidates() async {
if (_initialLoaded || _isLoadingInitial) {
return;
}
final repository = _repository;
if (repository == null) {
return;
}
setState(() {
_isLoadingInitial = true;
});
try {
final results = await repository.listInitial(limit: _pageSize);
if (!mounted) {
return;
}
setState(() {
_initialSuggestions.clear();
_initialSuggestions.addAll(results);
if (_selected != null &&
!_initialSuggestions.any((item) => item.id == _selected!.id)) {
_initialSuggestions.insert(0, _selected!);
}
if (_textController.text.trim().isEmpty) {
_suggestions.clear();
_suggestions.addAll(_initialSuggestions);
}
_isLoadingInitial = false;
_initialLoaded = true;
});
} catch (_) {
if (!mounted) {
return;
}
setState(() {
_isLoadingInitial = false;
_initialLoaded = false;
});
}
}
void _handleFocusChange() {
if (_focusNode.hasFocus) {
if (!_initialLoaded && !_isLoadingInitial) {
unawaited(_prefetchInitialCandidates());
} else if (_textController.text.trim().isEmpty &&
_initialSuggestions.isNotEmpty) {
setState(() {
_suggestions.clear();
_suggestions.addAll(_initialSuggestions);
});
}
return;
}
unawaited(_confirmManualEntry(_textController.text));
}
void _scheduleSearch(String keyword) {
_debounce?.cancel();
_debounce = Timer(_debounceDuration, () {
unawaited(_search(keyword));
});
}
String _displayLabel(ApprovalApproverCatalogItem item) {
return '${item.name} (${item.employeeNo}) · ${item.team}';
Future<void> _search(String keyword) async {
final repository = _repository;
final trimmed = keyword.trim();
if (repository == null || trimmed.isEmpty) {
if (mounted) {
setState(() {
_suggestions.clear();
_isSearching = false;
});
}
return;
}
final request = ++_requestId;
setState(() {
_isSearching = true;
});
try {
final results = await repository.search(
keyword: trimmed,
limit: _pageSize,
);
if (!mounted || request != _requestId) {
return;
}
setState(() {
_suggestions.clear();
_suggestions.addAll(results);
_isSearching = false;
});
} catch (_) {
if (!mounted || request != _requestId) {
return;
}
setState(() {
_suggestions.clear();
_isSearching = false;
});
}
}
/// 사용자가 직접 입력한 사번(ID)을 기반으로 값을 결정한다.
void _applyManualEntry(String value) {
Future<void> _confirmManualEntry(String value) async {
final trimmed = value.trim();
if (trimmed.isEmpty) {
_handleCleared();
_clearSelection();
return;
}
final manualId = int.tryParse(trimmed.replaceAll(RegExp(r'[^0-9]'), ''));
if (manualId == null) {
return;
}
final match = ApprovalApproverCatalog.byId(manualId);
if (match != null) {
_handleSelected(match);
final repository = _repository;
if (repository == null) {
return;
}
try {
final candidate = await repository.fetchById(manualId);
if (!mounted) {
return;
}
if (candidate == null) {
_clearSelection();
return;
}
_applySelection(candidate);
} catch (_) {
if (!mounted) {
return;
}
_clearSelection();
}
}
void _applySelection(ApprovalApproverCandidate candidate) {
setState(() {
_selected = candidate;
widget.idController.text = candidate.id.toString();
_isSearching = false;
});
_ensureCandidateCached(candidate);
_setText(_displayLabel(candidate));
widget.onSelected?.call(candidate);
}
void _clearSelection({
bool useInitialSuggestions = false,
bool resetText = true,
}) {
final hadSelection =
_selected != null || widget.idController.text.isNotEmpty;
setState(() {
_selected = null;
_isSearching = false;
_suggestions.clear();
if (useInitialSuggestions && _initialSuggestions.isNotEmpty) {
_suggestions.addAll(_initialSuggestions);
}
});
if (hadSelection) {
widget.idController.clear();
}
widget.onSelected?.call(null);
if (resetText || useInitialSuggestions || hadSelection) {
_setText('');
}
}
String _displayLabel(ApprovalApproverCandidate candidate) {
final team = candidate.team?.trim();
if (team == null || team.isEmpty) {
return '${candidate.name} (${candidate.employeeNo})';
}
return '${candidate.name} (${candidate.employeeNo}) · $team';
}
void _ensureCandidateCached(ApprovalApproverCandidate candidate) {
if (!mounted) {
return;
}
final exists = _initialSuggestions.any((item) => item.id == candidate.id);
if (exists) {
return;
}
setState(() {
_selected = null;
widget.idController.text = manualId.toString();
_textController.text = '직접 입력: $manualId';
widget.onSelected?.call(null);
_initialSuggestions.insert(0, candidate);
if (_textController.text.trim().isEmpty) {
_suggestions.clear();
_suggestions.addAll(_initialSuggestions);
}
});
}
/// 포커스가 해제될 때 수동 입력을 확정한다.
void _handleFocusChange() {
if (!_focusNode.hasFocus) {
_applyManualEntry(_textController.text);
}
void _setText(String value) {
_isApplyingText = true;
_textController.text = value;
_textController.selection = TextSelection.collapsed(offset: value.length);
_isApplyingText = false;
}
@override
void dispose() {
_debounce?.cancel();
_textController.dispose();
_focusNode.removeListener(_handleFocusChange);
_focusNode.dispose();
super.dispose();
}
@override
@@ -122,135 +331,124 @@ class _ApprovalApproverAutocompleteFieldState
final theme = ShadTheme.of(context);
return LayoutBuilder(
builder: (context, constraints) {
return RawAutocomplete<ApprovalApproverCatalogItem>(
final maxWidth = constraints.maxWidth.isFinite
? constraints.maxWidth
: 360.0;
return RawAutocomplete<ApprovalApproverCandidate>(
textEditingController: _textController,
focusNode: _focusNode,
optionsBuilder: (textEditingValue) {
final text = textEditingValue.text.trim();
if (text.isEmpty) {
return const Iterable<ApprovalApproverCatalogItem>.empty();
}
return _options(text);
return _suggestions;
},
displayStringForOption: _displayLabel,
onSelected: _handleSelected,
onSelected: _applySelection,
fieldViewBuilder:
(context, textController, focusNode, onFieldSubmitted) {
return ShadInput(
controller: textController,
focusNode: focusNode,
placeholder: Text(widget.hintText ?? '승인자 이름 또는 사번 검색'),
onChanged: (value) {
if (value.isEmpty) {
_handleCleared();
} else if (_selected != null &&
value != _displayLabel(_selected!)) {
setState(() {
_selected = null;
widget.idController.clear();
});
}
},
onSubmitted: (_) {
_applyManualEntry(textController.text);
onFieldSubmitted();
},
onPressedOutside: (event) {
// 드롭다운에서 항목을 고르기 전에 포커스를 잃지 않도록 한다.
focusNode.requestFocus();
},
assert(identical(textController, _textController));
return Stack(
alignment: Alignment.centerRight,
children: [
ShadInput(
controller: textController,
focusNode: focusNode,
placeholder: Text(widget.hintText ?? '승인자 이름 또는 사번 검색'),
onChanged: (value) {
if (_isApplyingText) {
return;
}
if (value.trim().isEmpty) {
_debounce?.cancel();
_clearSelection(useInitialSuggestions: true);
if (!_initialLoaded && !_isLoadingInitial) {
unawaited(_prefetchInitialCandidates());
}
} else {
_scheduleSearch(value);
}
},
onSubmitted: (_) {
unawaited(_confirmManualEntry(textController.text));
onFieldSubmitted();
},
onPressedOutside: (_) => focusNode.requestFocus(),
),
if (_isSearching ||
(_isLoadingInitial &&
textController.text.trim().isEmpty))
const Padding(
padding: EdgeInsets.only(right: 12),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
],
);
},
optionsViewBuilder: (context, onSelected, options) {
if (options.isEmpty) {
return Listener(
onPointerDown: (_) {
if (!_focusNode.hasPrimaryFocus) {
_focusNode.requestFocus();
}
},
child: Align(
alignment: AlignmentDirectional.topStart,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constraints.maxWidth,
maxHeight: 220,
),
child: Material(
elevation: 6,
color: theme.colorScheme.background,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: theme.colorScheme.border),
),
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Text(
'일치하는 승인자를 찾지 못했습니다.',
style: theme.textTheme.muted,
),
),
),
),
final isInitialLoadInProgress =
_isLoadingInitial && _textController.text.trim().isEmpty;
if (_isSearching || isInitialLoadInProgress) {
return _buildDropdownWrapper(
maxWidth: maxWidth,
maxHeight: 220,
theme: theme,
child: const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
return Listener(
onPointerDown: (_) {
if (!_focusNode.hasPrimaryFocus) {
_focusNode.requestFocus();
}
},
child: Align(
alignment: AlignmentDirectional.topStart,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constraints.maxWidth,
maxHeight: 260,
),
child: Material(
elevation: 6,
color: theme.colorScheme.background,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: theme.colorScheme.border),
),
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 6),
itemCount: options.length,
itemBuilder: (context, index) {
final option = options.elementAt(index);
return InkWell(
onTap: () => onSelected(option),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${option.name} · ${option.team}',
style: theme.textTheme.p,
),
const SizedBox(height: 4),
Text(
'ID ${option.id} · ${option.employeeNo}',
style: theme.textTheme.muted.copyWith(
fontSize: 12,
),
),
],
),
),
);
},
),
if (options.isEmpty) {
final hasKeyword = _textController.text.trim().isNotEmpty;
final message = hasKeyword
? '일치하는 승인자를 찾지 못했습니다.'
: '표시할 승인자가 없습니다.';
return _buildDropdownWrapper(
maxWidth: maxWidth,
maxHeight: 220,
theme: theme,
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Text(message, style: theme.textTheme.muted),
),
),
);
}
return _buildDropdownWrapper(
maxWidth: maxWidth,
maxHeight: 260,
theme: theme,
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 6),
itemCount: options.length,
itemBuilder: (context, index) {
final option = options.elementAt(index);
return InkWell(
onTap: () => onSelected(option),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(option.name, style: theme.textTheme.p),
const SizedBox(height: 4),
Text(
_optionSubtitle(option),
style: theme.textTheme.muted.copyWith(fontSize: 12),
),
],
),
),
);
},
),
);
},
@@ -259,12 +457,35 @@ class _ApprovalApproverAutocompleteFieldState
);
}
@override
void dispose() {
_textController.dispose();
_focusNode
..removeListener(_handleFocusChange)
..dispose();
super.dispose();
Widget _buildDropdownWrapper({
required double maxWidth,
required double maxHeight,
required Widget child,
required ShadThemeData theme,
}) {
return Align(
alignment: AlignmentDirectional.topStart,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth, maxHeight: maxHeight),
child: Material(
elevation: 6,
color: theme.colorScheme.background,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: theme.colorScheme.border),
),
child: child,
),
),
);
}
String _optionSubtitle(ApprovalApproverCandidate candidate) {
final team = candidate.team?.trim();
final buffer = StringBuffer('ID ${candidate.id} · ${candidate.employeeNo}');
if (team != null && team.isNotEmpty) {
buffer.write(' · $team');
}
return buffer.toString();
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
import 'package:intl/intl.dart' as intl;
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../../core/config/environment.dart';
@@ -15,6 +16,7 @@ import '../../../domain/entities/approval_template.dart';
import '../../../domain/repositories/approval_template_repository.dart';
import '../../../domain/usecases/apply_approval_template_use_case.dart';
import '../../../domain/usecases/save_approval_template_use_case.dart';
import '../../../../auth/application/auth_service.dart';
import '../controllers/approval_template_controller.dart';
/// 결재 템플릿 관리 페이지. 기능 플래그에 따라 준비중 화면을 노출한다.
@@ -69,7 +71,7 @@ class _ApprovalTemplateEnabledPageState
late final ApprovalTemplateController _controller;
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocus = FocusNode();
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm');
final intl.DateFormat _dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
String? _lastError;
static const _pageSizeOptions = [10, 20, 50];
@@ -354,6 +356,50 @@ class _ApprovalTemplateEnabledPageState
_searchFocus.requestFocus();
}
String _generateTemplateCode() {
final authService = GetIt.I<AuthService>();
final session = authService.session;
String normalizedEmployee = '';
final candidateValues = <String?>[
session?.user.employeeNo,
session?.user.email,
session?.user.name,
];
for (final candidate in candidateValues) {
if (candidate == null) {
continue;
}
var source = candidate.trim();
final atIndex = source.indexOf('@');
if (atIndex > 0) {
source = source.substring(0, atIndex);
}
final normalized = source.toUpperCase().replaceAll(
RegExp(r'[^A-Z0-9]'),
'',
);
if (normalized.isNotEmpty) {
normalizedEmployee = normalized;
break;
}
}
if (normalizedEmployee.isEmpty && session?.user.id != null) {
normalizedEmployee = session!.user.id.toString();
}
final suffixSource = normalizedEmployee.isEmpty
? '0000'
: normalizedEmployee;
final suffix = suffixSource.length >= 4
? suffixSource.substring(suffixSource.length - 4)
: suffixSource.padLeft(4, '0');
final timestamp = intl.DateFormat(
'yyMMddHHmmssSSS',
).format(DateTime.now().toUtc());
return 'AP_TEMP_${suffix}_$timestamp';
}
Future<void> _openTemplatePreview(int templateId) async {
showDialog<void>(
context: context,
@@ -530,7 +576,9 @@ class _ApprovalTemplateEnabledPageState
Future<bool?> _openTemplateForm({ApprovalTemplate? template}) async {
final isEdit = template != null;
final existingTemplate = template;
final codeController = TextEditingController(text: template?.code ?? '');
final codeController = TextEditingController(
text: isEdit ? existingTemplate!.code : _generateTemplateCode(),
);
final nameController = TextEditingController(text: template?.name ?? '');
final descriptionController = TextEditingController(
text: template?.description ?? '',
@@ -573,7 +621,7 @@ class _ApprovalTemplateEnabledPageState
)
.toList();
final input = ApprovalTemplateInput(
code: isEdit ? existingTemplate?.code : codeValue,
code: isEdit ? existingTemplate!.code : codeValue,
name: nameValue,
description: descriptionController.text.trim().isEmpty
? null
@@ -583,16 +631,11 @@ class _ApprovalTemplateEnabledPageState
: noteController.text.trim(),
isActive: statusNotifier.value,
);
if (isEdit && existingTemplate == null) {
modalSetState?.call(() => errorText = '템플릿 정보를 불러오지 못했습니다.');
modalSetState?.call(() => isSaving = false);
return;
}
modalSetState?.call(() => isSaving = true);
final success = isEdit && existingTemplate != null
? await _controller.update(existingTemplate.id, input, stepInputs)
final success = isEdit
? await _controller.update(existingTemplate!.id, input, stepInputs)
: await _controller.create(input, stepInputs);
if (success != null && mounted) {
Navigator.of(context, rootNavigator: true).pop(true);
@@ -622,6 +665,8 @@ class _ApprovalTemplateEnabledPageState
label: '템플릿 코드',
child: ShadInput(
controller: codeController,
readOnly: true,
enabled: false,
placeholder: const Text('예: AP_INBOUND'),
),
),