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:
@@ -38,7 +38,7 @@ flutter run -d chrome --web-renderer canvaskit --dart-define=ENV=development
|
|||||||
- 현재 연동된 주요 리소스
|
- 현재 연동된 주요 리소스
|
||||||
- `/customers`, `/vendors`, `/products`, `/uoms`, `/users`, `/groups`, `/menus`, `/group-menu-permissions`
|
- `/customers`, `/vendors`, `/products`, `/uoms`, `/users`, `/groups`, `/menus`, `/group-menu-permissions`
|
||||||
- `/warehouses`, `/transaction-types`, `/transaction-statuses`, `/approval-statuses`, `/approval-actions`
|
- `/warehouses`, `/transaction-types`, `/transaction-statuses`, `/approval-statuses`, `/approval-actions`
|
||||||
- `/approvals`, `/approval-steps`, `/approval-templates`, `/approval-histories`
|
- `/approvals`, `/approval-steps`, `/approval/templates`, `/approval-histories`
|
||||||
- `/stock-transactions`(lines/customers 포함), `/reports/downloads`
|
- `/stock-transactions`(lines/customers 포함), `/reports/downloads`
|
||||||
- `/zipcodes` (우편번호 검색)
|
- `/zipcodes` (우편번호 검색)
|
||||||
- API 응답 실패는 `Failure.describe()`를 통해 토스트/다이얼로그로 노출되며, 필드 검증 오류와 일반 메시지를 자동 병합한다.
|
- API 응답 실패는 `Failure.describe()`를 통해 토스트/다이얼로그로 노출되며, 필드 검증 오류와 일반 메시지를 자동 병합한다.
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
- [x] 단계 행위: 승인/반려/코멘트 버튼(가능 여부 상태에 따라 비활성/툴팁) (현황: 단계 버튼·툴팁·행위 다이얼로그를 구현했고 `ApprovalRepository.performStepAction` 연동 완료, 권한 기반 노출/후속 알림은 TODO)
|
- [x] 단계 행위: 승인/반려/코멘트 버튼(가능 여부 상태에 따라 비활성/툴팁) (현황: 단계 버튼·툴팁·행위 다이얼로그를 구현했고 `ApprovalRepository.performStepAction` 연동 완료, 권한 기반 노출/후속 알림은 TODO)
|
||||||
- [x] 단계 관리(`/approval-steps`): 목록/편집(신규/수정) (현황: 목록/필터 + 상세/신규/수정 모달 UI를 구현하고 컨트롤러에서 생성·수정 호출까지 연동, 삭제/권한 제어는 후속 예정)
|
- [x] 단계 관리(`/approval-steps`): 목록/편집(신규/수정) (현황: 목록/필터 + 상세/신규/수정 모달 UI를 구현하고 컨트롤러에서 생성·수정 호출까지 연동, 삭제/권한 제어는 후속 예정)
|
||||||
- [x] 이력(`/approval-histories`): 조회 전용 테이블 (현황: AppLayout 기반 필터·페이지네이션 테이블과 기간 선택/엑셀 비활성 버튼까지 구현, 다운로드 API 연동은 후속 예정)
|
- [x] 이력(`/approval-histories`): 조회 전용 테이블 (현황: AppLayout 기반 필터·페이지네이션 테이블과 기간 선택/엑셀 비활성 버튼까지 구현, 다운로드 API 연동은 후속 예정)
|
||||||
- [x] 템플릿(`/approval-templates`): 목록/헤더+단계 반복 폼 (현황: AppLayout + FilterBar + 페이지네이션 테이블과 생성/수정/삭제/복구 플로우를 구현했고 단계 등록 API까지 연동 완료, 승인자 자동완성·권한 제어 등 추가 UX는 후속 예정)
|
- [x] 템플릿(`/approval/templates`): 목록/헤더+단계 반복 폼 (현황: AppLayout + FilterBar + 페이지네이션 테이블과 생성/수정/삭제/복구 플로우를 구현했고 단계 등록 API까지 연동 완료, 승인자 자동완성·권한 제어 등 추가 UX는 후속 예정)
|
||||||
|
|
||||||
### Approval Flow v2 (신규)
|
### Approval Flow v2 (신규)
|
||||||
- [ ] 입고/출고/대여 등록 폼에 결재 단계 구성 섹션 추가 (`ApprovalStepConfigurator` 모달/섹션, `ShadTable` 기반 리스트)
|
- [ ] 입고/출고/대여 등록 폼에 결재 단계 구성 섹션 추가 (`ApprovalStepConfigurator` 모달/섹션, `ShadTable` 기반 리스트)
|
||||||
|
|||||||
@@ -183,7 +183,7 @@
|
|||||||
- 테이블 전용: 번호, 결재ID, 단계ID, 승인자, 행위, 변경전상태, 변경후상태, 작업일시, 비고.
|
- 테이블 전용: 번호, 결재ID, 단계ID, 승인자, 행위, 변경전상태, 변경후상태, 작업일시, 비고.
|
||||||
|
|
||||||
### 5.16 결재 템플릿 관리
|
### 5.16 결재 템플릿 관리
|
||||||
- 라우트: `/approval-templates`
|
- 라우트: `/approval/templates`
|
||||||
- 테이블: 번호, 템플릿코드, 템플릿명, 설명, 작성자, 사용여부, 변경일시.
|
- 테이블: 번호, 템플릿코드, 템플릿명, 설명, 작성자, 사용여부, 변경일시.
|
||||||
- 신규/수정:
|
- 신규/수정:
|
||||||
- 헤더: 템플릿코드[TXT], 템플릿명[TXT], 설명[TXT], 작성자[RO], 사용여부[SW], 비고[TXT].
|
- 헤더: 템플릿코드[TXT], 템플릿명[TXT], 설명[TXT], 작성자[RO], 사용여부[SW], 비고[TXT].
|
||||||
@@ -523,7 +523,7 @@
|
|||||||
- 생성: `POST /stock-transactions` 바디 내 헤더/라인/고객 배열 동시 전달
|
- 생성: `POST /stock-transactions` 바디 내 헤더/라인/고객 배열 동시 전달
|
||||||
- 결재 상세: `GET /approvals/{id}?include=steps,histories`
|
- 결재 상세: `GET /approvals/{id}?include=steps,histories`
|
||||||
- 단계 행위: `POST /approval-steps/{id}/actions` with `approval_action_id`
|
- 단계 행위: `POST /approval-steps/{id}/actions` with `approval_action_id`
|
||||||
- 결재 템플릿: `GET/POST/PATCH /approval-templates`, `POST/PATCH /approval-templates/{id}/steps`
|
- 결재 템플릿: `GET/POST/PATCH /approval/templates`, `POST/PATCH /approval/templates/{id}/steps`
|
||||||
- 룩업: `/uoms`, `/transaction-types`, `/transaction-statuses`, `/approval-statuses`, `/approval-actions`
|
- 룩업: `/uoms`, `/transaction-types`, `/transaction-statuses`, `/approval-statuses`, `/approval-actions`
|
||||||
|
|
||||||
## 20. 컴포넌트 매핑(shadcn_ui)
|
## 20. 컴포넌트 매핑(shadcn_ui)
|
||||||
|
|||||||
@@ -1758,10 +1758,10 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 6. 결재 템플릿 API
|
## 6. 결재 템플릿 API
|
||||||
리소스: `/approval-templates`
|
리소스: `/approval/templates`
|
||||||
|
|
||||||
### 6.1 목록 조회
|
### 6.1 목록 조회
|
||||||
`GET /approval-templates?page=1`
|
`GET /approval/templates?page=1`
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"items": [
|
"items": [
|
||||||
@@ -1788,7 +1788,7 @@
|
|||||||
- `created_by`는 작성자의 `id`, `employee_id`, `name`을 포함하며 `include=` 파라미터 없이도 기본 반환된다.
|
- `created_by`는 작성자의 `id`, `employee_id`, `name`을 포함하며 `include=` 파라미터 없이도 기본 반환된다.
|
||||||
|
|
||||||
### 6.2 단건 조회
|
### 6.2 단건 조회
|
||||||
`GET /approval-templates/3001?include=steps`
|
`GET /approval/templates/3001?include=steps`
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"data": {
|
"data": {
|
||||||
@@ -1821,7 +1821,7 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 6.3 생성·수정
|
### 6.3 생성·수정
|
||||||
- `POST /approval-templates`
|
- `POST /approval/templates`
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"template_code": "AP_OUTBOUND",
|
"template_code": "AP_OUTBOUND",
|
||||||
@@ -1832,7 +1832,7 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `POST /approval-templates/3002/steps`
|
- `POST /approval/templates/3002/steps`
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": 3002,
|
"id": 3002,
|
||||||
@@ -1849,7 +1849,7 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `PATCH /approval-templates/3002`
|
- `PATCH /approval/templates/3002`
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": 3002,
|
"id": 3002,
|
||||||
@@ -1858,7 +1858,7 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `PATCH /approval-templates/3002/steps`
|
- `PATCH /approval/templates/3002/steps`
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": 3002,
|
"id": 3002,
|
||||||
@@ -1872,7 +1872,7 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- 삭제/복구: `DELETE /approval-templates/{id}`, `POST /approval-templates/{id}/restore`
|
- 삭제/복구: `DELETE /approval/templates/{id}`, `POST /approval/templates/{id}/restore`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ zipcodes ||--o{ customers : addressed
|
|||||||
| created_at | 생성일시 | timestamp | - | now() | Y | | | |
|
| created_at | 생성일시 | timestamp | - | now() | Y | | | |
|
||||||
| updated_at | 변경일시 | timestamp | - | now() | Y | | | |
|
| updated_at | 변경일시 | timestamp | - | now() | Y | | | |
|
||||||
|
|
||||||
> API 기본 응답(`GET /approval-templates`, `GET /approval-templates/{id}`)은 작성자 요약(`created_by { id, employee_id, name }`)을 항상 포함하며, `include=created_by` 없이도 반환된다.
|
> API 기본 응답(`GET /approval/templates`, `GET /approval/templates/{id}`)은 작성자 요약(`created_by { id, employee_id, name }`)을 항상 포함하며, `include=created_by` 없이도 반환된다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ class ApiRoutes {
|
|||||||
static const approvalHistory = '$apiV1/approval/history';
|
static const approvalHistory = '$apiV1/approval/history';
|
||||||
static const approvalActions = '$apiV1/approval-actions';
|
static const approvalActions = '$apiV1/approval-actions';
|
||||||
static const approvalDrafts = '$apiV1/approval-drafts';
|
static const approvalDrafts = '$apiV1/approval-drafts';
|
||||||
|
static const approvalTemplates = '$apiV1/approval/templates';
|
||||||
|
static const approvalTemplatesLegacy = '$apiV1/approval-templates';
|
||||||
|
|
||||||
/// 결재 행위 전용 경로(`/approval/{action}`)를 반환한다.
|
/// 결재 행위 전용 경로(`/approval/{action}`)를 반환한다.
|
||||||
static String approvalAction(String action) {
|
static String approvalAction(String action) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class PermissionResources {
|
|||||||
static const String approvals = '/approvals';
|
static const String approvals = '/approvals';
|
||||||
static const String approvalSteps = '/approval-steps';
|
static const String approvalSteps = '/approval-steps';
|
||||||
static const String approvalHistories = '/approval-histories';
|
static const String approvalHistories = '/approval-histories';
|
||||||
static const String approvalTemplates = '/approval-templates';
|
static const String approvalTemplates = '/approval/templates';
|
||||||
static const String groupMenuPermissions = '/group-menu-permissions';
|
static const String groupMenuPermissions = '/group-menu-permissions';
|
||||||
static const String vendors = '/vendors';
|
static const String vendors = '/vendors';
|
||||||
static const String products = '/products';
|
static const String products = '/products';
|
||||||
@@ -39,6 +39,7 @@ class PermissionResources {
|
|||||||
'/approvals/histories': approvalHistories,
|
'/approvals/histories': approvalHistories,
|
||||||
'/approval-histories': approvalHistories,
|
'/approval-histories': approvalHistories,
|
||||||
'/approvals/templates': approvalTemplates,
|
'/approvals/templates': approvalTemplates,
|
||||||
|
'/approval/templates': approvalTemplates,
|
||||||
'/approval-templates': approvalTemplates,
|
'/approval-templates': approvalTemplates,
|
||||||
'/masters/group-permissions': groupMenuPermissions,
|
'/masters/group-permissions': groupMenuPermissions,
|
||||||
'/group-menu-permissions': groupMenuPermissions,
|
'/group-menu-permissions': groupMenuPermissions,
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
|
|||||||
|
|
||||||
final ApiClient _api;
|
final ApiClient _api;
|
||||||
|
|
||||||
static const _basePath = '${ApiRoutes.apiV1}/approval-templates';
|
static const _basePaths = <String>[
|
||||||
|
ApiRoutes.approvalTemplates,
|
||||||
|
ApiRoutes.approvalTemplatesLegacy,
|
||||||
|
];
|
||||||
|
|
||||||
/// 결재 템플릿 목록을 조회한다. 검색/활성 여부 필터를 지원한다.
|
/// 결재 템플릿 목록을 조회한다. 검색/활성 여부 필터를 지원한다.
|
||||||
@override
|
@override
|
||||||
@@ -27,17 +30,19 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
|
|||||||
String? query,
|
String? query,
|
||||||
bool? isActive,
|
bool? isActive,
|
||||||
}) async {
|
}) async {
|
||||||
final response = await _api.get<Map<String, dynamic>>(
|
return _withTemplateRoute((basePath) async {
|
||||||
_basePath,
|
final response = await _api.get<Map<String, dynamic>>(
|
||||||
query: {
|
basePath,
|
||||||
'page': page,
|
query: {
|
||||||
'page_size': pageSize,
|
'page': page,
|
||||||
if (query != null && query.isNotEmpty) 'q': query,
|
'page_size': pageSize,
|
||||||
if (isActive != null) 'active': isActive,
|
if (query != null && query.isNotEmpty) 'q': query,
|
||||||
},
|
if (isActive != null) 'active': isActive,
|
||||||
options: Options(responseType: ResponseType.json),
|
},
|
||||||
);
|
options: Options(responseType: ResponseType.json),
|
||||||
return ApprovalTemplateDto.parsePaginated(response.data);
|
);
|
||||||
|
return ApprovalTemplateDto.parsePaginated(response.data);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 템플릿 상세 정보를 조회한다. 필요 시 단계 포함 여부를 지정한다.
|
/// 템플릿 상세 정보를 조회한다. 필요 시 단계 포함 여부를 지정한다.
|
||||||
@@ -46,14 +51,17 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
|
|||||||
int id, {
|
int id, {
|
||||||
bool includeSteps = true,
|
bool includeSteps = true,
|
||||||
}) async {
|
}) async {
|
||||||
final response = await _api.get<Map<String, dynamic>>(
|
return _withTemplateRoute((basePath) async {
|
||||||
'$_basePath/$id',
|
final path = ApiClient.buildPath(basePath, [id]);
|
||||||
query: {if (includeSteps) 'include': 'steps'},
|
final response = await _api.get<Map<String, dynamic>>(
|
||||||
options: Options(responseType: ResponseType.json),
|
path,
|
||||||
);
|
query: {if (includeSteps) 'include': 'steps'},
|
||||||
return ApprovalTemplateDto.fromJson(
|
options: Options(responseType: ResponseType.json),
|
||||||
_api.unwrapAsMap(response),
|
);
|
||||||
).toEntity(includeSteps: includeSteps);
|
return ApprovalTemplateDto.fromJson(
|
||||||
|
_api.unwrapAsMap(response),
|
||||||
|
).toEntity(includeSteps: includeSteps);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 템플릿을 생성하고 필요하면 단계까지 함께 등록한다.
|
/// 템플릿을 생성하고 필요하면 단계까지 함께 등록한다.
|
||||||
@@ -62,18 +70,20 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
|
|||||||
ApprovalTemplateInput input, {
|
ApprovalTemplateInput input, {
|
||||||
List<ApprovalTemplateStepInput> steps = const [],
|
List<ApprovalTemplateStepInput> steps = const [],
|
||||||
}) async {
|
}) async {
|
||||||
final response = await _api.post<Map<String, dynamic>>(
|
return _withTemplateRoute((basePath) async {
|
||||||
_basePath,
|
final response = await _api.post<Map<String, dynamic>>(
|
||||||
data: input.toCreatePayload(),
|
basePath,
|
||||||
options: Options(responseType: ResponseType.json),
|
data: input.toCreatePayload(),
|
||||||
);
|
options: Options(responseType: ResponseType.json),
|
||||||
final created = ApprovalTemplateDto.fromJson(
|
);
|
||||||
_api.unwrapAsMap(response),
|
final created = ApprovalTemplateDto.fromJson(
|
||||||
).toEntity(includeSteps: false);
|
_api.unwrapAsMap(response),
|
||||||
if (steps.isNotEmpty) {
|
).toEntity(includeSteps: false);
|
||||||
await _postSteps(created.id, steps);
|
if (steps.isNotEmpty) {
|
||||||
}
|
await _postSteps(created.id, steps, basePath: basePath);
|
||||||
return fetchDetail(created.id, includeSteps: true);
|
}
|
||||||
|
return fetchDetail(created.id, includeSteps: true);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 템플릿 기본 정보와 단계 구성을 수정한다.
|
/// 템플릿 기본 정보와 단계 구성을 수정한다.
|
||||||
@@ -83,43 +93,54 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
|
|||||||
ApprovalTemplateInput input, {
|
ApprovalTemplateInput input, {
|
||||||
List<ApprovalTemplateStepInput>? steps,
|
List<ApprovalTemplateStepInput>? steps,
|
||||||
}) async {
|
}) async {
|
||||||
await _api.patch<Map<String, dynamic>>(
|
return _withTemplateRoute((basePath) async {
|
||||||
'$_basePath/$id',
|
final path = ApiClient.buildPath(basePath, [id]);
|
||||||
data: input.toUpdatePayload(id),
|
await _api.patch<Map<String, dynamic>>(
|
||||||
options: Options(responseType: ResponseType.json),
|
path,
|
||||||
);
|
data: input.toUpdatePayload(id),
|
||||||
if (steps != null) {
|
options: Options(responseType: ResponseType.json),
|
||||||
await _patchSteps(id, steps);
|
);
|
||||||
}
|
if (steps != null) {
|
||||||
return fetchDetail(id, includeSteps: true);
|
await _patchSteps(id, steps, basePath: basePath);
|
||||||
|
}
|
||||||
|
return fetchDetail(id, includeSteps: true);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 템플릿을 삭제한다.
|
/// 템플릿을 삭제한다.
|
||||||
@override
|
@override
|
||||||
Future<void> delete(int id) async {
|
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
|
@override
|
||||||
Future<ApprovalTemplate> restore(int id) async {
|
Future<ApprovalTemplate> restore(int id) async {
|
||||||
final response = await _api.post<Map<String, dynamic>>(
|
return _withTemplateRoute((basePath) async {
|
||||||
'$_basePath/$id/restore',
|
final path = ApiClient.buildPath(basePath, [id, 'restore']);
|
||||||
options: Options(responseType: ResponseType.json),
|
final response = await _api.post<Map<String, dynamic>>(
|
||||||
);
|
path,
|
||||||
return ApprovalTemplateDto.fromJson(
|
options: Options(responseType: ResponseType.json),
|
||||||
_api.unwrapAsMap(response),
|
);
|
||||||
).toEntity(includeSteps: false);
|
return ApprovalTemplateDto.fromJson(
|
||||||
|
_api.unwrapAsMap(response),
|
||||||
|
).toEntity(includeSteps: false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 템플릿 단계 전체를 신규로 등록한다.
|
/// 템플릿 단계 전체를 신규로 등록한다.
|
||||||
Future<void> _postSteps(
|
Future<void> _postSteps(
|
||||||
int templateId,
|
int templateId,
|
||||||
List<ApprovalTemplateStepInput> steps,
|
List<ApprovalTemplateStepInput> steps, {
|
||||||
) async {
|
required String basePath,
|
||||||
|
}) async {
|
||||||
if (steps.isEmpty) return;
|
if (steps.isEmpty) return;
|
||||||
|
final path = ApiClient.buildPath(basePath, [templateId, 'steps']);
|
||||||
await _api.post<Map<String, dynamic>>(
|
await _api.post<Map<String, dynamic>>(
|
||||||
'$_basePath/$templateId/steps',
|
path,
|
||||||
data: {
|
data: {
|
||||||
'id': templateId,
|
'id': templateId,
|
||||||
'steps': steps.map((step) => step.toJson(includeId: false)).toList(),
|
'steps': steps.map((step) => step.toJson(includeId: false)).toList(),
|
||||||
@@ -131,10 +152,12 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
|
|||||||
/// 템플릿 단계 정보를 부분 수정한다.
|
/// 템플릿 단계 정보를 부분 수정한다.
|
||||||
Future<void> _patchSteps(
|
Future<void> _patchSteps(
|
||||||
int templateId,
|
int templateId,
|
||||||
List<ApprovalTemplateStepInput> steps,
|
List<ApprovalTemplateStepInput> steps, {
|
||||||
) async {
|
required String basePath,
|
||||||
|
}) async {
|
||||||
|
final path = ApiClient.buildPath(basePath, [templateId, 'steps']);
|
||||||
await _api.patch<Map<String, dynamic>>(
|
await _api.patch<Map<String, dynamic>>(
|
||||||
'$_basePath/$templateId/steps',
|
path,
|
||||||
data: {
|
data: {
|
||||||
'id': templateId,
|
'id': templateId,
|
||||||
'steps': steps.map((step) => step.toJson()).toList(),
|
'steps': steps.map((step) => step.toJson()).toList(),
|
||||||
@@ -142,4 +165,25 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
|
|||||||
options: Options(responseType: ResponseType.json),
|
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('템플릿 경로 후보가 정의되지 않았습니다.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ import '../../../domain/entities/approval_flow.dart';
|
|||||||
import '../../../domain/repositories/approval_repository.dart';
|
import '../../../domain/repositories/approval_repository.dart';
|
||||||
import '../../../domain/usecases/recall_approval_use_case.dart';
|
import '../../../domain/usecases/recall_approval_use_case.dart';
|
||||||
import '../../../domain/usecases/resubmit_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/widgets.dart';
|
||||||
import '../../../shared/widgets/approver_autocomplete_field.dart';
|
import '../../../shared/widgets/approver_autocomplete_field.dart';
|
||||||
import '../../../shared/approver_catalog.dart';
|
|
||||||
import '../controllers/approval_history_controller.dart';
|
import '../controllers/approval_history_controller.dart';
|
||||||
import '../widgets/approval_audit_log_table.dart';
|
import '../widgets/approval_audit_log_table.dart';
|
||||||
import '../widgets/approval_flow_timeline.dart';
|
import '../widgets/approval_flow_timeline.dart';
|
||||||
@@ -315,7 +315,7 @@ class _ApprovalHistoryEnabledPageState
|
|||||||
_refreshAuditForSelectedRecord(resetPage: true);
|
_refreshAuditForSelectedRecord(resetPage: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleAuditActorSelected(ApprovalApproverCatalogItem? item) {
|
void _handleAuditActorSelected(ApprovalApproverCandidate? item) {
|
||||||
final selectedId = item?.id ?? int.tryParse(_auditActorIdController.text);
|
final selectedId = item?.id ?? int.tryParse(_auditActorIdController.text);
|
||||||
_controller.updateAuditActor(selectedId);
|
_controller.updateAuditActor(selectedId);
|
||||||
_refreshAuditForSelectedRecord(resetPage: true);
|
_refreshAuditForSelectedRecord(resetPage: true);
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
|
||||||
import '../../../domain/entities/approval.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 '../controllers/approval_request_controller.dart';
|
||||||
import '../../../../inventory/transactions/domain/entities/stock_transaction_input.dart';
|
import '../../../../inventory/transactions/domain/entities/stock_transaction_input.dart';
|
||||||
|
|
||||||
@@ -10,12 +14,13 @@ class ApprovalFormInitializer {
|
|||||||
ApprovalFormInitializer._();
|
ApprovalFormInitializer._();
|
||||||
|
|
||||||
/// 결재 구성 컨트롤러에 기본값을 주입한다.
|
/// 결재 구성 컨트롤러에 기본값을 주입한다.
|
||||||
static void populate({
|
static Future<void> populate({
|
||||||
required ApprovalRequestController controller,
|
required ApprovalRequestController controller,
|
||||||
Approval? existingApproval,
|
Approval? existingApproval,
|
||||||
StockTransactionApprovalInput? draft,
|
StockTransactionApprovalInput? draft,
|
||||||
ApprovalRequestParticipant? defaultRequester,
|
ApprovalRequestParticipant? defaultRequester,
|
||||||
}) {
|
ApprovalApproverRepository? repository,
|
||||||
|
}) async {
|
||||||
if (existingApproval != null) {
|
if (existingApproval != null) {
|
||||||
_applyExistingApproval(controller, existingApproval);
|
_applyExistingApproval(controller, existingApproval);
|
||||||
return;
|
return;
|
||||||
@@ -24,7 +29,11 @@ class ApprovalFormInitializer {
|
|||||||
controller.setRequester(defaultRequester);
|
controller.setRequester(defaultRequester);
|
||||||
}
|
}
|
||||||
if (draft != null) {
|
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,
|
ApprovalRequestController controller,
|
||||||
StockTransactionApprovalInput draft,
|
StockTransactionApprovalInput draft,
|
||||||
) {
|
ApprovalApproverRepository? repository,
|
||||||
final requesterCatalog = ApprovalApproverCatalog.byId(draft.requestedById);
|
) async {
|
||||||
if (requesterCatalog != null) {
|
final repo = repository;
|
||||||
controller.setRequester(
|
if (repo == null) {
|
||||||
ApprovalRequestParticipant(
|
return;
|
||||||
id: requesterCatalog.id,
|
|
||||||
name: requesterCatalog.name,
|
|
||||||
employeeNo: requesterCatalog.employeeNo,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
final steps = draft.steps
|
|
||||||
.map((step) {
|
final requester = await _fetchParticipant(repo, draft.requestedById);
|
||||||
final catalog = ApprovalApproverCatalog.byId(step.approverId);
|
if (requester != null) {
|
||||||
if (catalog == null) {
|
controller.setRequester(requester);
|
||||||
|
}
|
||||||
|
|
||||||
|
final futures = draft.steps
|
||||||
|
.map((step) async {
|
||||||
|
final participant = await _fetchParticipant(repo, step.approverId);
|
||||||
|
if (participant == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return ApprovalRequestStep(
|
return ApprovalRequestStep(
|
||||||
stepOrder: step.stepOrder,
|
stepOrder: step.stepOrder,
|
||||||
approver: ApprovalRequestParticipant(
|
approver: participant,
|
||||||
id: catalog.id,
|
|
||||||
name: catalog.name,
|
|
||||||
employeeNo: catalog.employeeNo,
|
|
||||||
),
|
|
||||||
note: step.note,
|
note: step.note,
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.whereType<ApprovalRequestStep>()
|
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
|
|
||||||
|
final resolvedSteps = await Future.wait(futures);
|
||||||
|
final steps = resolvedSteps.whereType<ApprovalRequestStep>().toList();
|
||||||
if (steps.isNotEmpty) {
|
if (steps.isNotEmpty) {
|
||||||
controller.applyTemplateSteps(steps);
|
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>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
|||||||
|
|
||||||
import '../../../../../widgets/components/feedback.dart';
|
import '../../../../../widgets/components/feedback.dart';
|
||||||
import '../../../../../widgets/components/superport_dialog.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 '../../../shared/widgets/approver_autocomplete_field.dart';
|
||||||
import '../controllers/approval_request_controller.dart';
|
import '../controllers/approval_request_controller.dart';
|
||||||
import 'approval_step_row.dart';
|
import 'approval_step_row.dart';
|
||||||
@@ -435,7 +435,7 @@ class _ConfiguratorDialogBodyState extends State<_ConfiguratorDialogBody> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openAddStepDialog() async {
|
Future<void> _openAddStepDialog() async {
|
||||||
ApprovalApproverCatalogItem? selected;
|
ApprovalApproverCandidate? selected;
|
||||||
final idController = TextEditingController();
|
final idController = TextEditingController();
|
||||||
|
|
||||||
final result = await SuperportDialog.show<bool>(
|
final result = await SuperportDialog.show<bool>(
|
||||||
@@ -483,13 +483,19 @@ class _ConfiguratorDialogBodyState extends State<_ConfiguratorDialogBody> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final participant = _resolveParticipant(selected, idController.text.trim());
|
final selection = selected;
|
||||||
if (participant == null) {
|
if (selection == null) {
|
||||||
SuperportToast.warning(context, '유효한 승인자를 선택해주세요.');
|
SuperportToast.warning(context, '유효한 승인자를 선택해주세요.');
|
||||||
idController.dispose();
|
idController.dispose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final participant = ApprovalRequestParticipant(
|
||||||
|
id: selection.id,
|
||||||
|
name: selection.name,
|
||||||
|
employeeNo: selection.employeeNo,
|
||||||
|
);
|
||||||
|
|
||||||
final added = widget.controller.addStep(approver: participant);
|
final added = widget.controller.addStep(approver: participant);
|
||||||
if (!added) {
|
if (!added) {
|
||||||
final message = widget.controller.errorMessage ?? '결재 단계를 추가하지 못했습니다.';
|
final message = widget.controller.errorMessage ?? '결재 단계를 추가하지 못했습니다.';
|
||||||
@@ -503,31 +509,6 @@ class _ConfiguratorDialogBodyState extends State<_ConfiguratorDialogBody> {
|
|||||||
idController.dispose();
|
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 {
|
class _InfoBadge extends StatelessWidget {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
|||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
import '../../../../../widgets/components/feedback.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 '../../../shared/widgets/approver_autocomplete_field.dart';
|
||||||
import '../controllers/approval_request_controller.dart';
|
import '../controllers/approval_request_controller.dart';
|
||||||
|
|
||||||
@@ -96,37 +96,23 @@ class _ApprovalStepRowState extends State<ApprovalStepRow> {
|
|||||||
|
|
||||||
Future<void> _handleApproverSelected(
|
Future<void> _handleApproverSelected(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
ApprovalApproverCatalogItem? item,
|
ApprovalApproverCandidate? candidate,
|
||||||
) async {
|
) async {
|
||||||
if (widget.readOnly) {
|
if (widget.readOnly) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ApprovalRequestParticipant? nextParticipant;
|
ApprovalRequestParticipant? nextParticipant;
|
||||||
|
|
||||||
if (item != null) {
|
if (candidate != null) {
|
||||||
nextParticipant = ApprovalRequestParticipant(
|
nextParticipant = ApprovalRequestParticipant(
|
||||||
id: item.id,
|
id: candidate.id,
|
||||||
name: item.name,
|
name: candidate.name,
|
||||||
employeeNo: item.employeeNo,
|
employeeNo: candidate.employeeNo,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
final manualId = int.tryParse(_approverIdController.text.trim());
|
SuperportToast.warning(context, '승인자를 다시 선택해주세요.');
|
||||||
if (manualId == null) {
|
_restorePreviousApprover();
|
||||||
SuperportToast.warning(context, '승인자를 다시 선택해주세요.');
|
return;
|
||||||
_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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final updated = widget.controller.updateStep(
|
final updated = widget.controller.updateStep(
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)';
|
||||||
|
}
|
||||||
@@ -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});
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.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';
|
||||||
|
|
||||||
/// 승인자 자동완성 필드.
|
/// 승인자 자동완성 필드.
|
||||||
///
|
///
|
||||||
/// - 사용자가 이름/사번으로 검색하면 일치하는 승인자를 제안한다.
|
/// - 이름/사번을 입력하면 API에서 승인자 후보를 검색한다.
|
||||||
/// - 항목을 선택하면 `idController`에 승인자 ID가 채워진다.
|
/// - 항목 선택 시 `idController`에 승인자 ID를 기록한다.
|
||||||
class ApprovalApproverAutocompleteField extends StatefulWidget {
|
class ApprovalApproverAutocompleteField extends StatefulWidget {
|
||||||
const ApprovalApproverAutocompleteField({
|
const ApprovalApproverAutocompleteField({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -17,104 +21,309 @@ class ApprovalApproverAutocompleteField extends StatefulWidget {
|
|||||||
|
|
||||||
final TextEditingController idController;
|
final TextEditingController idController;
|
||||||
final String? hintText;
|
final String? hintText;
|
||||||
final void Function(ApprovalApproverCatalogItem?)? onSelected;
|
final void Function(ApprovalApproverCandidate?)? onSelected;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ApprovalApproverAutocompleteField> createState() =>
|
State<ApprovalApproverAutocompleteField> createState() =>
|
||||||
_ApprovalApproverAutocompleteFieldState();
|
_ApprovalApproverAutocompleteFieldState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 승인자 자동완성 필드의 내부 상태를 관리한다.
|
|
||||||
class _ApprovalApproverAutocompleteFieldState
|
class _ApprovalApproverAutocompleteFieldState
|
||||||
extends State<ApprovalApproverAutocompleteField> {
|
extends State<ApprovalApproverAutocompleteField> {
|
||||||
late final TextEditingController _textController;
|
static const _debounceDuration = Duration(milliseconds: 250);
|
||||||
late final FocusNode _focusNode;
|
static const _pageSize = 15;
|
||||||
ApprovalApproverCatalogItem? _selected;
|
|
||||||
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_textController = TextEditingController();
|
|
||||||
_focusNode = FocusNode();
|
|
||||||
_focusNode.addListener(_handleFocusChange);
|
_focusNode.addListener(_handleFocusChange);
|
||||||
_syncFromId();
|
_initializeFromId();
|
||||||
|
unawaited(_prefetchInitialCandidates());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 외부에서 제공된 ID 값으로부터 표시 문자열을 동기화한다.
|
@override
|
||||||
void _syncFromId() {
|
void didUpdateWidget(covariant ApprovalApproverAutocompleteField oldWidget) {
|
||||||
final idText = widget.idController.text.trim();
|
super.didUpdateWidget(oldWidget);
|
||||||
final id = int.tryParse(idText);
|
if (!identical(widget.idController, oldWidget.idController)) {
|
||||||
final match = ApprovalApproverCatalog.byId(id);
|
_initializeFromId();
|
||||||
if (match != null) {
|
|
||||||
_selected = match;
|
|
||||||
_textController.text = _displayLabel(match);
|
|
||||||
} else if (id != null) {
|
|
||||||
_selected = null;
|
|
||||||
_textController.text = '직접 입력: $id';
|
|
||||||
} else {
|
|
||||||
_selected = null;
|
|
||||||
_textController.clear();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 검색어에 매칭되는 승인자 목록을 반환한다.
|
Future<void> _initializeFromId() async {
|
||||||
Iterable<ApprovalApproverCatalogItem> _options(String query) {
|
final idText = widget.idController.text.trim();
|
||||||
return ApprovalApproverCatalog.filter(query);
|
final id = int.tryParse(idText);
|
||||||
}
|
if (id == null) {
|
||||||
|
setState(() {
|
||||||
/// 특정 승인자를 선택했을 때 내부 상태와 콜백을 갱신한다.
|
_selected = null;
|
||||||
void _handleSelected(ApprovalApproverCatalogItem item) {
|
_setText('');
|
||||||
setState(() {
|
});
|
||||||
_selected = item;
|
return;
|
||||||
widget.idController.text = item.id.toString();
|
}
|
||||||
_textController.text = _displayLabel(item);
|
final repository = _repository;
|
||||||
widget.onSelected?.call(item);
|
if (repository == null) {
|
||||||
});
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
/// 선택된 값을 초기화한다.
|
final candidate = await repository.fetchById(id);
|
||||||
void _handleCleared() {
|
if (!mounted) {
|
||||||
setState(() {
|
return;
|
||||||
_selected = null;
|
}
|
||||||
widget.idController.clear();
|
if (candidate == null) {
|
||||||
_textController.clear();
|
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);
|
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) {
|
Future<void> _search(String keyword) async {
|
||||||
return '${item.name} (${item.employeeNo}) · ${item.team}';
|
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)을 기반으로 값을 결정한다.
|
Future<void> _confirmManualEntry(String value) async {
|
||||||
void _applyManualEntry(String value) {
|
|
||||||
final trimmed = value.trim();
|
final trimmed = value.trim();
|
||||||
if (trimmed.isEmpty) {
|
if (trimmed.isEmpty) {
|
||||||
_handleCleared();
|
_clearSelection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final manualId = int.tryParse(trimmed.replaceAll(RegExp(r'[^0-9]'), ''));
|
final manualId = int.tryParse(trimmed.replaceAll(RegExp(r'[^0-9]'), ''));
|
||||||
if (manualId == null) {
|
if (manualId == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final match = ApprovalApproverCatalog.byId(manualId);
|
final repository = _repository;
|
||||||
if (match != null) {
|
if (repository == null) {
|
||||||
_handleSelected(match);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_selected = null;
|
_initialSuggestions.insert(0, candidate);
|
||||||
widget.idController.text = manualId.toString();
|
if (_textController.text.trim().isEmpty) {
|
||||||
_textController.text = '직접 입력: $manualId';
|
_suggestions.clear();
|
||||||
widget.onSelected?.call(null);
|
_suggestions.addAll(_initialSuggestions);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 포커스가 해제될 때 수동 입력을 확정한다.
|
void _setText(String value) {
|
||||||
void _handleFocusChange() {
|
_isApplyingText = true;
|
||||||
if (!_focusNode.hasFocus) {
|
_textController.text = value;
|
||||||
_applyManualEntry(_textController.text);
|
_textController.selection = TextSelection.collapsed(offset: value.length);
|
||||||
}
|
_isApplyingText = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_debounce?.cancel();
|
||||||
|
_textController.dispose();
|
||||||
|
_focusNode.removeListener(_handleFocusChange);
|
||||||
|
_focusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -122,135 +331,124 @@ class _ApprovalApproverAutocompleteFieldState
|
|||||||
final theme = ShadTheme.of(context);
|
final theme = ShadTheme.of(context);
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
return RawAutocomplete<ApprovalApproverCatalogItem>(
|
final maxWidth = constraints.maxWidth.isFinite
|
||||||
|
? constraints.maxWidth
|
||||||
|
: 360.0;
|
||||||
|
return RawAutocomplete<ApprovalApproverCandidate>(
|
||||||
textEditingController: _textController,
|
textEditingController: _textController,
|
||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
optionsBuilder: (textEditingValue) {
|
optionsBuilder: (textEditingValue) {
|
||||||
final text = textEditingValue.text.trim();
|
return _suggestions;
|
||||||
if (text.isEmpty) {
|
|
||||||
return const Iterable<ApprovalApproverCatalogItem>.empty();
|
|
||||||
}
|
|
||||||
return _options(text);
|
|
||||||
},
|
},
|
||||||
displayStringForOption: _displayLabel,
|
displayStringForOption: _displayLabel,
|
||||||
onSelected: _handleSelected,
|
onSelected: _applySelection,
|
||||||
fieldViewBuilder:
|
fieldViewBuilder:
|
||||||
(context, textController, focusNode, onFieldSubmitted) {
|
(context, textController, focusNode, onFieldSubmitted) {
|
||||||
return ShadInput(
|
assert(identical(textController, _textController));
|
||||||
controller: textController,
|
return Stack(
|
||||||
focusNode: focusNode,
|
alignment: Alignment.centerRight,
|
||||||
placeholder: Text(widget.hintText ?? '승인자 이름 또는 사번 검색'),
|
children: [
|
||||||
onChanged: (value) {
|
ShadInput(
|
||||||
if (value.isEmpty) {
|
controller: textController,
|
||||||
_handleCleared();
|
focusNode: focusNode,
|
||||||
} else if (_selected != null &&
|
placeholder: Text(widget.hintText ?? '승인자 이름 또는 사번 검색'),
|
||||||
value != _displayLabel(_selected!)) {
|
onChanged: (value) {
|
||||||
setState(() {
|
if (_isApplyingText) {
|
||||||
_selected = null;
|
return;
|
||||||
widget.idController.clear();
|
}
|
||||||
});
|
if (value.trim().isEmpty) {
|
||||||
}
|
_debounce?.cancel();
|
||||||
},
|
_clearSelection(useInitialSuggestions: true);
|
||||||
onSubmitted: (_) {
|
if (!_initialLoaded && !_isLoadingInitial) {
|
||||||
_applyManualEntry(textController.text);
|
unawaited(_prefetchInitialCandidates());
|
||||||
onFieldSubmitted();
|
}
|
||||||
},
|
} else {
|
||||||
onPressedOutside: (event) {
|
_scheduleSearch(value);
|
||||||
// 드롭다운에서 항목을 고르기 전에 포커스를 잃지 않도록 한다.
|
}
|
||||||
focusNode.requestFocus();
|
},
|
||||||
},
|
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) {
|
optionsViewBuilder: (context, onSelected, options) {
|
||||||
if (options.isEmpty) {
|
final isInitialLoadInProgress =
|
||||||
return Listener(
|
_isLoadingInitial && _textController.text.trim().isEmpty;
|
||||||
onPointerDown: (_) {
|
if (_isSearching || isInitialLoadInProgress) {
|
||||||
if (!_focusNode.hasPrimaryFocus) {
|
return _buildDropdownWrapper(
|
||||||
_focusNode.requestFocus();
|
maxWidth: maxWidth,
|
||||||
}
|
maxHeight: 220,
|
||||||
},
|
theme: theme,
|
||||||
child: Align(
|
child: const Center(
|
||||||
alignment: AlignmentDirectional.topStart,
|
child: Padding(
|
||||||
child: ConstrainedBox(
|
padding: EdgeInsets.symmetric(vertical: 24),
|
||||||
constraints: BoxConstraints(
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Listener(
|
if (options.isEmpty) {
|
||||||
onPointerDown: (_) {
|
final hasKeyword = _textController.text.trim().isNotEmpty;
|
||||||
if (!_focusNode.hasPrimaryFocus) {
|
final message = hasKeyword
|
||||||
_focusNode.requestFocus();
|
? '일치하는 승인자를 찾지 못했습니다.'
|
||||||
}
|
: '표시할 승인자가 없습니다.';
|
||||||
},
|
return _buildDropdownWrapper(
|
||||||
child: Align(
|
maxWidth: maxWidth,
|
||||||
alignment: AlignmentDirectional.topStart,
|
maxHeight: 220,
|
||||||
child: ConstrainedBox(
|
theme: theme,
|
||||||
constraints: BoxConstraints(
|
child: Center(
|
||||||
maxWidth: constraints.maxWidth,
|
child: Padding(
|
||||||
maxHeight: 260,
|
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||||
),
|
child: Text(message, style: theme.textTheme.muted),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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
|
Widget _buildDropdownWrapper({
|
||||||
void dispose() {
|
required double maxWidth,
|
||||||
_textController.dispose();
|
required double maxHeight,
|
||||||
_focusNode
|
required Widget child,
|
||||||
..removeListener(_handleFocusChange)
|
required ShadThemeData theme,
|
||||||
..dispose();
|
}) {
|
||||||
super.dispose();
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
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 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
import '../../../../../core/config/environment.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/repositories/approval_template_repository.dart';
|
||||||
import '../../../domain/usecases/apply_approval_template_use_case.dart';
|
import '../../../domain/usecases/apply_approval_template_use_case.dart';
|
||||||
import '../../../domain/usecases/save_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';
|
import '../controllers/approval_template_controller.dart';
|
||||||
|
|
||||||
/// 결재 템플릿 관리 페이지. 기능 플래그에 따라 준비중 화면을 노출한다.
|
/// 결재 템플릿 관리 페이지. 기능 플래그에 따라 준비중 화면을 노출한다.
|
||||||
@@ -69,7 +71,7 @@ class _ApprovalTemplateEnabledPageState
|
|||||||
late final ApprovalTemplateController _controller;
|
late final ApprovalTemplateController _controller;
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
final FocusNode _searchFocus = FocusNode();
|
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;
|
String? _lastError;
|
||||||
static const _pageSizeOptions = [10, 20, 50];
|
static const _pageSizeOptions = [10, 20, 50];
|
||||||
|
|
||||||
@@ -354,6 +356,50 @@ class _ApprovalTemplateEnabledPageState
|
|||||||
_searchFocus.requestFocus();
|
_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 {
|
Future<void> _openTemplatePreview(int templateId) async {
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -530,7 +576,9 @@ class _ApprovalTemplateEnabledPageState
|
|||||||
Future<bool?> _openTemplateForm({ApprovalTemplate? template}) async {
|
Future<bool?> _openTemplateForm({ApprovalTemplate? template}) async {
|
||||||
final isEdit = template != null;
|
final isEdit = template != null;
|
||||||
final existingTemplate = template;
|
final existingTemplate = template;
|
||||||
final codeController = TextEditingController(text: template?.code ?? '');
|
final codeController = TextEditingController(
|
||||||
|
text: isEdit ? existingTemplate!.code : _generateTemplateCode(),
|
||||||
|
);
|
||||||
final nameController = TextEditingController(text: template?.name ?? '');
|
final nameController = TextEditingController(text: template?.name ?? '');
|
||||||
final descriptionController = TextEditingController(
|
final descriptionController = TextEditingController(
|
||||||
text: template?.description ?? '',
|
text: template?.description ?? '',
|
||||||
@@ -573,7 +621,7 @@ class _ApprovalTemplateEnabledPageState
|
|||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
final input = ApprovalTemplateInput(
|
final input = ApprovalTemplateInput(
|
||||||
code: isEdit ? existingTemplate?.code : codeValue,
|
code: isEdit ? existingTemplate!.code : codeValue,
|
||||||
name: nameValue,
|
name: nameValue,
|
||||||
description: descriptionController.text.trim().isEmpty
|
description: descriptionController.text.trim().isEmpty
|
||||||
? null
|
? null
|
||||||
@@ -583,16 +631,11 @@ class _ApprovalTemplateEnabledPageState
|
|||||||
: noteController.text.trim(),
|
: noteController.text.trim(),
|
||||||
isActive: statusNotifier.value,
|
isActive: statusNotifier.value,
|
||||||
);
|
);
|
||||||
if (isEdit && existingTemplate == null) {
|
|
||||||
modalSetState?.call(() => errorText = '템플릿 정보를 불러오지 못했습니다.');
|
|
||||||
modalSetState?.call(() => isSaving = false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
modalSetState?.call(() => isSaving = true);
|
modalSetState?.call(() => isSaving = true);
|
||||||
|
|
||||||
final success = isEdit && existingTemplate != null
|
final success = isEdit
|
||||||
? await _controller.update(existingTemplate.id, input, stepInputs)
|
? await _controller.update(existingTemplate!.id, input, stepInputs)
|
||||||
: await _controller.create(input, stepInputs);
|
: await _controller.create(input, stepInputs);
|
||||||
if (success != null && mounted) {
|
if (success != null && mounted) {
|
||||||
Navigator.of(context, rootNavigator: true).pop(true);
|
Navigator.of(context, rootNavigator: true).pop(true);
|
||||||
@@ -622,6 +665,8 @@ class _ApprovalTemplateEnabledPageState
|
|||||||
label: '템플릿 코드',
|
label: '템플릿 코드',
|
||||||
child: ShadInput(
|
child: ShadInput(
|
||||||
controller: codeController,
|
controller: codeController,
|
||||||
|
readOnly: true,
|
||||||
|
enabled: false,
|
||||||
placeholder: const Text('예: AP_INBOUND'),
|
placeholder: const Text('예: AP_INBOUND'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1515,12 +1515,15 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
employeeNo: writer.employeeNo,
|
employeeNo: writer.employeeNo,
|
||||||
);
|
);
|
||||||
}();
|
}();
|
||||||
ApprovalFormInitializer.populate(
|
await ApprovalFormInitializer.populate(
|
||||||
controller: approvalController,
|
controller: approvalController,
|
||||||
existingApproval: initial?.raw?.approval,
|
existingApproval: initial?.raw?.approval,
|
||||||
draft: _controller?.approvalDraft,
|
draft: _controller?.approvalDraft,
|
||||||
defaultRequester: defaultRequester,
|
defaultRequester: defaultRequester,
|
||||||
);
|
);
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
final writerController = TextEditingController(
|
final writerController = TextEditingController(
|
||||||
text: writerLabel(writerSelection),
|
text: writerLabel(writerSelection),
|
||||||
|
|||||||
@@ -1609,12 +1609,15 @@ class _OutboundPageState extends State<OutboundPage> {
|
|||||||
employeeNo: writer.employeeNo,
|
employeeNo: writer.employeeNo,
|
||||||
);
|
);
|
||||||
}();
|
}();
|
||||||
ApprovalFormInitializer.populate(
|
await ApprovalFormInitializer.populate(
|
||||||
controller: approvalController,
|
controller: approvalController,
|
||||||
existingApproval: initial?.raw?.approval,
|
existingApproval: initial?.raw?.approval,
|
||||||
draft: _controller?.approvalDraft,
|
draft: _controller?.approvalDraft,
|
||||||
defaultRequester: defaultRequester,
|
defaultRequester: defaultRequester,
|
||||||
);
|
);
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
final writerController = TextEditingController(
|
final writerController = TextEditingController(
|
||||||
text: writerLabel(writerSelection),
|
text: writerLabel(writerSelection),
|
||||||
|
|||||||
@@ -1591,12 +1591,15 @@ class _RentalPageState extends State<RentalPage> {
|
|||||||
employeeNo: writer.employeeNo,
|
employeeNo: writer.employeeNo,
|
||||||
);
|
);
|
||||||
}();
|
}();
|
||||||
ApprovalFormInitializer.populate(
|
await ApprovalFormInitializer.populate(
|
||||||
controller: approvalController,
|
controller: approvalController,
|
||||||
existingApproval: initial?.raw?.approval,
|
existingApproval: initial?.raw?.approval,
|
||||||
draft: _controller?.approvalDraft,
|
draft: _controller?.approvalDraft,
|
||||||
defaultRequester: defaultRequester,
|
defaultRequester: defaultRequester,
|
||||||
);
|
);
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
final writerController = TextEditingController(
|
final writerController = TextEditingController(
|
||||||
text: writerLabel(writerSelection),
|
text: writerLabel(writerSelection),
|
||||||
|
|||||||
@@ -263,11 +263,22 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
),
|
),
|
||||||
if (kDebugMode) ...[
|
if (kDebugMode) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
// QA 요청: 테스트 로그인 버튼을 테마 색상과 굵은 서체로 강조하여 피드백 반영.
|
// QA 요청: 디버그 로그인 버튼을 테마 색상과 굵은 서체로 강조하여 피드백 반영.
|
||||||
ShadButton.ghost(
|
ShadButton.ghost(
|
||||||
onPressed: isLoading ? null : _handleTestLogin,
|
onPressed: isLoading ? null : _handleTeraLogin,
|
||||||
child: Text(
|
child: Text(
|
||||||
'테스트 로그인',
|
'tera로그인',
|
||||||
|
style: theme.textTheme.small.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ShadButton.ghost(
|
||||||
|
onPressed: isLoading ? null : _handleExaLogin,
|
||||||
|
child: Text(
|
||||||
|
'exa로그인',
|
||||||
style: theme.textTheme.small.copyWith(
|
style: theme.textTheme.small.copyWith(
|
||||||
color: theme.colorScheme.primary,
|
color: theme.colorScheme.primary,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -304,17 +315,37 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 디버그 모드에서 테스트 계정으로 실서버 로그인한다.
|
/// 디버그 모드에서 테라(Tera) 계정으로 실서버 로그인한다.
|
||||||
void _handleTestLogin() {
|
void _handleTeraLogin() {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const testIdentifier = 'terabits';
|
const teraIdentifier = 'terabits';
|
||||||
const testPassword = '123456';
|
const teraPassword = '123456';
|
||||||
|
|
||||||
idController.text = testIdentifier;
|
idController.text = teraIdentifier;
|
||||||
passwordController.text = testPassword;
|
passwordController.text = teraPassword;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
rememberMe = false;
|
||||||
|
errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
_handleSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 디버그 모드에서 엑사(Exa) 계정으로 실서버 로그인한다.
|
||||||
|
void _handleExaLogin() {
|
||||||
|
if (isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exaIdentifier = 'exabits';
|
||||||
|
const exaPassword = '123456';
|
||||||
|
|
||||||
|
idController.text = exaIdentifier;
|
||||||
|
passwordController.text = exaPassword;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
rememberMe = false;
|
rememberMe = false;
|
||||||
|
|||||||
@@ -856,31 +856,36 @@ class _PermissionTable extends StatelessWidget {
|
|||||||
|
|
||||||
cells.add(
|
cells.add(
|
||||||
ShadTableCell(
|
ShadTableCell(
|
||||||
child: Row(
|
child: Align(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
alignment: Alignment.centerRight,
|
||||||
children: [
|
child: Wrap(
|
||||||
ShadButton.ghost(
|
spacing: 8,
|
||||||
size: ShadButtonSize.sm,
|
runSpacing: 6,
|
||||||
onPressed: onEdit == null ? null : () => onEdit!(permission),
|
alignment: WrapAlignment.end,
|
||||||
child: const Icon(LucideIcons.pencil, size: 16),
|
runAlignment: WrapAlignment.end,
|
||||||
),
|
children: [
|
||||||
const SizedBox(width: 8),
|
ShadButton.ghost(
|
||||||
permission.isDeleted
|
size: ShadButtonSize.sm,
|
||||||
? ShadButton.ghost(
|
onPressed: onEdit == null ? null : () => onEdit!(permission),
|
||||||
size: ShadButtonSize.sm,
|
child: const Icon(LucideIcons.pencil, size: 16),
|
||||||
onPressed: onRestore == null
|
),
|
||||||
? null
|
permission.isDeleted
|
||||||
: () => onRestore!(permission),
|
? ShadButton.ghost(
|
||||||
child: const Icon(LucideIcons.history, size: 16),
|
size: ShadButtonSize.sm,
|
||||||
)
|
onPressed: onRestore == null
|
||||||
: ShadButton.ghost(
|
? null
|
||||||
size: ShadButtonSize.sm,
|
: () => onRestore!(permission),
|
||||||
onPressed: onDelete == null
|
child: const Icon(LucideIcons.history, size: 16),
|
||||||
? null
|
)
|
||||||
: () => onDelete!(permission),
|
: ShadButton.ghost(
|
||||||
child: const Icon(LucideIcons.trash2, size: 16),
|
size: ShadButtonSize.sm,
|
||||||
),
|
onPressed: onDelete == null
|
||||||
],
|
? null
|
||||||
|
: () => onDelete!(permission),
|
||||||
|
child: const Icon(LucideIcons.trash2, size: 16),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class ProductController extends ChangeNotifier {
|
|||||||
|
|
||||||
List<Vendor> _vendorOptions = const [];
|
List<Vendor> _vendorOptions = const [];
|
||||||
List<Uom> _uomOptions = const [];
|
List<Uom> _uomOptions = const [];
|
||||||
|
bool _isDisposed = false;
|
||||||
|
|
||||||
PaginatedResult<Product>? get result => _result;
|
PaginatedResult<Product>? get result => _result;
|
||||||
bool get isLoading => _isLoading;
|
bool get isLoading => _isLoading;
|
||||||
@@ -60,7 +61,7 @@ class ProductController extends ChangeNotifier {
|
|||||||
Future<void> fetch({int page = 1}) async {
|
Future<void> fetch({int page = 1}) async {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
notifyListeners();
|
_notifySafely();
|
||||||
try {
|
try {
|
||||||
final previous = _result;
|
final previous = _result;
|
||||||
final int resolvedPage;
|
final int resolvedPage;
|
||||||
@@ -95,14 +96,14 @@ class ProductController extends ChangeNotifier {
|
|||||||
_errorMessage = failure.describe();
|
_errorMessage = failure.describe();
|
||||||
} finally {
|
} finally {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
_notifySafely();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 필터/폼에서 사용할 공급업체와 단위 목록을 로드한다.
|
/// 필터/폼에서 사용할 공급업체와 단위 목록을 로드한다.
|
||||||
Future<void> loadLookups() async {
|
Future<void> loadLookups() async {
|
||||||
_isLoadingLookups = true;
|
_isLoadingLookups = true;
|
||||||
notifyListeners();
|
_notifySafely();
|
||||||
try {
|
try {
|
||||||
debugPrint('[ProductController] 드롭다운 데이터 조회 시작');
|
debugPrint('[ProductController] 드롭다운 데이터 조회 시작');
|
||||||
final vendors = await fetchAllPaginatedItems<Vendor>(
|
final vendors = await fetchAllPaginatedItems<Vendor>(
|
||||||
@@ -127,7 +128,7 @@ class ProductController extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
_isLoadingLookups = false;
|
_isLoadingLookups = false;
|
||||||
notifyListeners();
|
_notifySafely();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +138,7 @@ class ProductController extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_query = value;
|
_query = value;
|
||||||
notifyListeners();
|
_notifySafely();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 공급업체 필터를 변경한다.
|
/// 공급업체 필터를 변경한다.
|
||||||
@@ -146,7 +147,7 @@ class ProductController extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_vendorFilter = vendorId;
|
_vendorFilter = vendorId;
|
||||||
notifyListeners();
|
_notifySafely();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 단위(UOM) 필터를 변경한다.
|
/// 단위(UOM) 필터를 변경한다.
|
||||||
@@ -155,7 +156,7 @@ class ProductController extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_uomFilter = uomId;
|
_uomFilter = uomId;
|
||||||
notifyListeners();
|
_notifySafely();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 사용 여부 필터를 변경한다.
|
/// 사용 여부 필터를 변경한다.
|
||||||
@@ -164,7 +165,7 @@ class ProductController extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_statusFilter = filter;
|
_statusFilter = filter;
|
||||||
notifyListeners();
|
_notifySafely();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 페이지 크기를 변경한다.
|
/// 페이지 크기를 변경한다.
|
||||||
@@ -173,7 +174,7 @@ class ProductController extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_pageSize = size;
|
_pageSize = size;
|
||||||
notifyListeners();
|
_notifySafely();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 제품을 생성한다.
|
/// 제품을 생성한다.
|
||||||
@@ -186,7 +187,7 @@ class ProductController extends ChangeNotifier {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
final failure = Failure.from(error);
|
final failure = Failure.from(error);
|
||||||
_errorMessage = failure.describe();
|
_errorMessage = failure.describe();
|
||||||
notifyListeners();
|
_notifySafely();
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
_setSubmitting(false);
|
_setSubmitting(false);
|
||||||
@@ -203,7 +204,7 @@ class ProductController extends ChangeNotifier {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
final failure = Failure.from(error);
|
final failure = Failure.from(error);
|
||||||
_errorMessage = failure.describe();
|
_errorMessage = failure.describe();
|
||||||
notifyListeners();
|
_notifySafely();
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
_setSubmitting(false);
|
_setSubmitting(false);
|
||||||
@@ -220,7 +221,7 @@ class ProductController extends ChangeNotifier {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
final failure = Failure.from(error);
|
final failure = Failure.from(error);
|
||||||
_errorMessage = failure.describe();
|
_errorMessage = failure.describe();
|
||||||
notifyListeners();
|
_notifySafely();
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
_setSubmitting(false);
|
_setSubmitting(false);
|
||||||
@@ -237,7 +238,7 @@ class ProductController extends ChangeNotifier {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
final failure = Failure.from(error);
|
final failure = Failure.from(error);
|
||||||
_errorMessage = failure.describe();
|
_errorMessage = failure.describe();
|
||||||
notifyListeners();
|
_notifySafely();
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
_setSubmitting(false);
|
_setSubmitting(false);
|
||||||
@@ -247,12 +248,26 @@ class ProductController extends ChangeNotifier {
|
|||||||
/// 에러 메시지를 초기화한다.
|
/// 에러 메시지를 초기화한다.
|
||||||
void clearError() {
|
void clearError() {
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
notifyListeners();
|
_notifySafely();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 제출 상태 플래그를 갱신하고 리스너에 알린다.
|
/// 제출 상태 플래그를 갱신하고 리스너에 알린다.
|
||||||
void _setSubmitting(bool value) {
|
void _setSubmitting(bool value) {
|
||||||
_isSubmitting = value;
|
_isSubmitting = value;
|
||||||
notifyListeners();
|
_notifySafely();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// dispose 이후에는 알림을 중단하여 디버그 에러를 방지한다.
|
||||||
|
void _notifySafely() {
|
||||||
|
if (_isDisposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
super.notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_isDisposed = true;
|
||||||
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import 'features/approvals/domain/usecases/save_approval_template_use_case.dart'
|
|||||||
import 'features/approvals/domain/usecases/submit_approval_use_case.dart';
|
import 'features/approvals/domain/usecases/submit_approval_use_case.dart';
|
||||||
import 'features/approvals/history/data/repositories/approval_history_repository_remote.dart';
|
import 'features/approvals/history/data/repositories/approval_history_repository_remote.dart';
|
||||||
import 'features/approvals/history/domain/repositories/approval_history_repository.dart';
|
import 'features/approvals/history/domain/repositories/approval_history_repository.dart';
|
||||||
|
import 'features/approvals/shared/data/repositories/approval_approver_repository_remote.dart';
|
||||||
|
import 'features/approvals/shared/domain/repositories/approval_approver_repository.dart';
|
||||||
import 'features/approvals/step/data/repositories/approval_step_repository_remote.dart';
|
import 'features/approvals/step/data/repositories/approval_step_repository_remote.dart';
|
||||||
import 'features/approvals/step/domain/repositories/approval_step_repository.dart';
|
import 'features/approvals/step/domain/repositories/approval_step_repository.dart';
|
||||||
import 'features/auth/application/auth_service.dart';
|
import 'features/auth/application/auth_service.dart';
|
||||||
@@ -187,6 +189,9 @@ void _registerApprovalDependencies() {
|
|||||||
..registerLazySingleton<ApprovalDraftRepository>(
|
..registerLazySingleton<ApprovalDraftRepository>(
|
||||||
() => ApprovalDraftRepositoryRemote(apiClient: sl<ApiClient>()),
|
() => ApprovalDraftRepositoryRemote(apiClient: sl<ApiClient>()),
|
||||||
)
|
)
|
||||||
|
..registerLazySingleton<ApprovalApproverRepository>(
|
||||||
|
() => ApprovalApproverRepositoryRemote(apiClient: sl<ApiClient>()),
|
||||||
|
)
|
||||||
..registerLazySingleton<SubmitApprovalUseCase>(
|
..registerLazySingleton<SubmitApprovalUseCase>(
|
||||||
() => SubmitApprovalUseCase(repository: sl<ApprovalRepository>()),
|
() => SubmitApprovalUseCase(repository: sl<ApprovalRepository>()),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,30 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
|
||||||
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
|
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
|
||||||
import 'package:superport_v2/features/approvals/request/presentation/controllers/approval_request_controller.dart';
|
import 'package:superport_v2/features/approvals/request/presentation/controllers/approval_request_controller.dart';
|
||||||
import 'package:superport_v2/features/approvals/request/presentation/utils/approval_form_initializer.dart';
|
import 'package:superport_v2/features/approvals/request/presentation/utils/approval_form_initializer.dart';
|
||||||
|
import 'package:superport_v2/features/approvals/shared/domain/entities/approval_approver_candidate.dart';
|
||||||
|
import 'package:superport_v2/features/approvals/shared/domain/repositories/approval_approver_repository.dart';
|
||||||
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
|
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
final getIt = GetIt.I;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
if (getIt.isRegistered<ApprovalApproverRepository>()) {
|
||||||
|
getIt.unregister<ApprovalApproverRepository>();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
if (getIt.isRegistered<ApprovalApproverRepository>()) {
|
||||||
|
getIt.unregister<ApprovalApproverRepository>();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
group('ApprovalFormInitializer.populate', () {
|
group('ApprovalFormInitializer.populate', () {
|
||||||
test('초안 저장본이 있으면 상신자와 단계를 복구한다', () {
|
test('초안 저장본이 있으면 상신자와 단계를 복구한다', () async {
|
||||||
final controller = ApprovalRequestController();
|
final controller = ApprovalRequestController();
|
||||||
final draft = StockTransactionApprovalInput(
|
final draft = StockTransactionApprovalInput(
|
||||||
requestedById: 101,
|
requestedById: 101,
|
||||||
@@ -17,7 +34,18 @@ void main() {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
ApprovalFormInitializer.populate(controller: controller, draft: draft);
|
getIt.registerSingleton<ApprovalApproverRepository>(
|
||||||
|
_FakeApproverRepository({
|
||||||
|
101: _candidate(id: 101, name: '요청자'),
|
||||||
|
104: _candidate(id: 104, name: '1단계 승인자'),
|
||||||
|
201: _candidate(id: 201, name: '최종 승인자'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await ApprovalFormInitializer.populate(
|
||||||
|
controller: controller,
|
||||||
|
draft: draft,
|
||||||
|
);
|
||||||
|
|
||||||
expect(controller.requester?.id, equals(101));
|
expect(controller.requester?.id, equals(101));
|
||||||
expect(controller.steps.length, equals(2));
|
expect(controller.steps.length, equals(2));
|
||||||
@@ -25,7 +53,7 @@ void main() {
|
|||||||
expect(controller.steps.last.approver.id, equals(201));
|
expect(controller.steps.last.approver.id, equals(201));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('카탈로그에 없는 승인자는 복구 대상에서 제외한다', () {
|
test('조회 실패 시 해당 승인자는 복구 대상에서 제외한다', () async {
|
||||||
final controller = ApprovalRequestController();
|
final controller = ApprovalRequestController();
|
||||||
final draft = StockTransactionApprovalInput(
|
final draft = StockTransactionApprovalInput(
|
||||||
requestedById: 101,
|
requestedById: 101,
|
||||||
@@ -35,10 +63,53 @@ void main() {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
ApprovalFormInitializer.populate(controller: controller, draft: draft);
|
getIt.registerSingleton<ApprovalApproverRepository>(
|
||||||
|
_FakeApproverRepository({
|
||||||
|
101: _candidate(id: 101, name: '요청자'),
|
||||||
|
104: _candidate(id: 104, name: '1단계 승인자'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await ApprovalFormInitializer.populate(
|
||||||
|
controller: controller,
|
||||||
|
draft: draft,
|
||||||
|
);
|
||||||
|
|
||||||
expect(controller.steps.length, equals(1));
|
expect(controller.steps.length, equals(1));
|
||||||
expect(controller.steps.first.approver.id, equals(104));
|
expect(controller.steps.first.approver.id, equals(104));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ApprovalApproverCandidate _candidate({required int id, required String name}) {
|
||||||
|
return ApprovalApproverCandidate(
|
||||||
|
id: id,
|
||||||
|
employeeNo: 'EMP$id',
|
||||||
|
name: name,
|
||||||
|
team: '팀',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeApproverRepository implements ApprovalApproverRepository {
|
||||||
|
_FakeApproverRepository(this._candidates);
|
||||||
|
|
||||||
|
final Map<int, ApprovalApproverCandidate> _candidates;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ApprovalApproverCandidate?> fetchById(int id) async {
|
||||||
|
return _candidates[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ApprovalApproverCandidate>> search({
|
||||||
|
required String keyword,
|
||||||
|
int limit = 20,
|
||||||
|
}) async {
|
||||||
|
return _candidates.values.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ApprovalApproverCandidate>> listInitial({int limit = 20}) async {
|
||||||
|
return _candidates.values.take(limit).toList(growable: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import 'package:superport_v2/features/approvals/domain/repositories/approval_tem
|
|||||||
import 'package:superport_v2/features/approvals/domain/usecases/apply_approval_template_use_case.dart';
|
import 'package:superport_v2/features/approvals/domain/usecases/apply_approval_template_use_case.dart';
|
||||||
import 'package:superport_v2/features/approvals/domain/usecases/save_approval_template_use_case.dart';
|
import 'package:superport_v2/features/approvals/domain/usecases/save_approval_template_use_case.dart';
|
||||||
import 'package:superport_v2/features/approvals/template/presentation/pages/approval_template_page.dart';
|
import 'package:superport_v2/features/approvals/template/presentation/pages/approval_template_page.dart';
|
||||||
|
import 'package:superport_v2/features/approvals/shared/domain/entities/approval_approver_candidate.dart';
|
||||||
|
import 'package:superport_v2/features/approvals/shared/domain/repositories/approval_approver_repository.dart';
|
||||||
|
import 'package:superport_v2/features/auth/application/auth_service.dart';
|
||||||
|
import 'package:superport_v2/features/auth/domain/entities/auth_session.dart';
|
||||||
|
import 'package:superport_v2/features/auth/domain/entities/authenticated_user.dart';
|
||||||
|
|
||||||
class _MockApprovalTemplateRepository extends Mock
|
class _MockApprovalTemplateRepository extends Mock
|
||||||
implements ApprovalTemplateRepository {}
|
implements ApprovalTemplateRepository {}
|
||||||
@@ -23,6 +28,8 @@ class _FakeTemplateInput extends Fake implements ApprovalTemplateInput {}
|
|||||||
class _FakeTemplateStepInput extends Fake
|
class _FakeTemplateStepInput extends Fake
|
||||||
implements ApprovalTemplateStepInput {}
|
implements ApprovalTemplateStepInput {}
|
||||||
|
|
||||||
|
class _MockAuthService extends Mock implements AuthService {}
|
||||||
|
|
||||||
Widget _buildApp(Widget child) {
|
Widget _buildApp(Widget child) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
home: ShadTheme(
|
home: ShadTheme(
|
||||||
@@ -82,6 +89,37 @@ void main() {
|
|||||||
approvalRepository: approvalRepository,
|
approvalRepository: approvalRepository,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
GetIt.I.registerLazySingleton<ApprovalApproverRepository>(
|
||||||
|
() => _FakeApproverRepository({
|
||||||
|
33: ApprovalApproverCandidate(
|
||||||
|
id: 33,
|
||||||
|
employeeNo: 'EMP033',
|
||||||
|
name: '테스트 승인자',
|
||||||
|
team: 'QA팀',
|
||||||
|
),
|
||||||
|
21: ApprovalApproverCandidate(
|
||||||
|
id: 21,
|
||||||
|
employeeNo: 'E001',
|
||||||
|
name: '최승인',
|
||||||
|
team: '결재팀',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
final authService = _MockAuthService();
|
||||||
|
when(() => authService.session).thenReturn(
|
||||||
|
AuthSession(
|
||||||
|
accessToken: 'test-access',
|
||||||
|
refreshToken: 'test-refresh',
|
||||||
|
expiresAt: DateTime(2099, 1, 1),
|
||||||
|
user: const AuthenticatedUser(
|
||||||
|
id: 99,
|
||||||
|
name: '테스트 사용자',
|
||||||
|
employeeNo: 'E999',
|
||||||
|
email: 'test@example.com',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
GetIt.I.registerSingleton<AuthService>(authService);
|
||||||
});
|
});
|
||||||
|
|
||||||
ApprovalTemplate buildTemplate({bool isActive = true}) {
|
ApprovalTemplate buildTemplate({bool isActive = true}) {
|
||||||
@@ -333,3 +371,38 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _FakeApproverRepository implements ApprovalApproverRepository {
|
||||||
|
_FakeApproverRepository(this._candidates);
|
||||||
|
|
||||||
|
final Map<int, ApprovalApproverCandidate> _candidates;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ApprovalApproverCandidate?> fetchById(int id) async {
|
||||||
|
return _candidates[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ApprovalApproverCandidate>> search({
|
||||||
|
required String keyword,
|
||||||
|
int limit = 20,
|
||||||
|
}) async {
|
||||||
|
final lower = keyword.trim().toLowerCase();
|
||||||
|
if (lower.isEmpty) {
|
||||||
|
return _candidates.values.toList(growable: false);
|
||||||
|
}
|
||||||
|
return _candidates.values
|
||||||
|
.where(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.name.toLowerCase().contains(lower) ||
|
||||||
|
candidate.employeeNo.toLowerCase().contains(lower) ||
|
||||||
|
candidate.id.toString().contains(lower),
|
||||||
|
)
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ApprovalApproverCandidate>> listInitial({int limit = 20}) async {
|
||||||
|
return _candidates.values.take(limit).toList(growable: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user