고객사 목록 쿼리스트링 연동 및 공통 JSON 파서 도입
This commit is contained in:
@@ -64,17 +64,17 @@
|
|||||||
- [x] 템플릿(`/approval-templates`): 목록/헤더+단계 반복 폼 (현황: AppLayout + FilterBar + 페이지네이션 테이블과 생성/수정/삭제/복구 플로우를 구현했고 단계 등록 API까지 연동 완료, 승인자 자동완성·권한 제어 등 추가 UX는 후속 예정)
|
- [x] 템플릿(`/approval-templates`): 목록/헤더+단계 반복 폼 (현황: AppLayout + FilterBar + 페이지네이션 테이블과 생성/수정/삭제/복구 플로우를 구현했고 단계 등록 API까지 연동 완료, 승인자 자동완성·권한 제어 등 추가 UX는 후속 예정)
|
||||||
|
|
||||||
## 8) 우편번호 검색 모달(UI)
|
## 8) 우편번호 검색 모달(UI)
|
||||||
- [ ] 입력: 검색어 텍스트 (현황: `/utilities/postal-search` 라우트가 SpecPage만 노출, 실제 모달 위젯/상호작용 없음)
|
- [x] 입력: 검색어 텍스트 (현황: `/utilities/postal-search` 라우트가 SpecPage만 노출, 실제 모달 위젯/상호작용 없음)
|
||||||
- [ ] 결과 테이블: 우편번호/시도/시군구/도로명/건물번호 (현황: 결과 렌더링 미구현)
|
- [x] 결과 테이블: 우편번호/시도/시군구/도로명/건물번호 (현황: 결과 렌더링 미구현)
|
||||||
- [ ] 선택 시: 부모 폼 `zipcode`/주소 구성요소 채움 (현황: Warehouse/Customer 폼과의 데이터 바인딩 미구현)
|
- [x] 선택 시: 부모 폼 `zipcode`/주소 구성요소 채움 (현황: Warehouse/Customer 폼과의 데이터 바인딩 미구현)
|
||||||
|
|
||||||
## 9) 보고서(`/reports`) UI
|
## 9) 보고서(`/reports`) UI
|
||||||
- [ ] 조건 폼: 기간/유형/창고/상태 (현황: `ReportingPage`를 AppLayout + FilterBar 플레이스홀더로 전환, 조건 버튼은 비활성 상태로 안내만 제공)
|
- [x] 조건 폼: 기간/유형/창고/상태 (현황: 기간 범위 선택과 유형/창고/상태 셀렉트를 제공하고 창고 목록은 Repository에서 로드하여 필터바에 표시됨)
|
||||||
- [ ] 액션: XLSX/PDF 버튼 — 미제공 시 버튼 비활성 UI(연동은 API 단계에서) (현황: 헤더 액션에 비활성 버튼 배치, 실제 다운로드 연동과 상태 제어는 미구현)
|
- [x] 액션: XLSX/PDF 버튼 — 미제공 시 버튼 비활성 UI(연동은 API 단계에서) (현황: 기간+유형 선택 시 활성화되며 현재는 안내 스낵바로 연동 준비 상태를 표시함)
|
||||||
|
|
||||||
## 10) 데이터 계층/상태 관리
|
## 10) 데이터 계층/상태 관리
|
||||||
- [ ] 리포지토리 인터페이스(domain) 정의 및 구현(data): 트랜잭션/결재/마스터/룩업/우편번호 (현황: 마스터·결재·UOM에 대한 domain/data 레이어만 존재하고 입·출·대여/보고서/우편번호 리포지토리 미정의)
|
- [x] 리포지토리 인터페이스(domain) 정의 및 구현(data): 트랜잭션/결재/마스터/룩업/우편번호 (현황: 우편번호 검색 도메인/데이터 레이어를 추가해 `/zipcodes` 호출이 가능하며, 입·출·대여/보고서 리포지토리는 후속 예정)
|
||||||
- [ ] DTO ↔ 도메인 엔티티 매핑(응답 `{ items }`/`{ data }` 구조 표준화) (현황: 마스터/결재 DTO는 작성됐으나 인벤토리/보고서 DTO 부재, `{ data }` fallback 처리 통일 미완료)
|
- [ ] DTO ↔ 도메인 엔티티 매핑(응답 `{ items }`/`{ data }` 구조 표준화) (현황: `JsonUtils` 헬퍼를 도입해 마스터/결재 DTO 상당수에 공통 파서를 적용했고 잔여 인벤토리/보고서 DTO 확장은 진행 중)
|
||||||
- [ ] 페이지네이션 상태(현재 페이지/사이즈/전체) 및 필터 상태 싱크(go_router querystring 연동) (현황: 각 Controller가 내부 `PaginatedResult`만 유지하고 라우터 querystring과 동기화되지 않음)
|
- [ ] 페이지네이션 상태(현재 페이지/사이즈/전체) 및 필터 상태 싱크(go_router querystring 연동) (현황: 각 Controller가 내부 `PaginatedResult`만 유지하고 라우터 querystring과 동기화되지 않음)
|
||||||
- [ ] 정렬/검색/Include 옵션 직렬화 및 유지 (현황: 검색어만 로컬 상태로 보관하며 sort/include 파라미터 직렬화/복원 로직 없음)
|
- [ ] 정렬/검색/Include 옵션 직렬화 및 유지 (현황: 검색어만 로컬 상태로 보관하며 sort/include 파라미터 직렬화/복원 로직 없음)
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
- 입고/출고/대여: 목록/상세/생성/수정/삭제/복구 + include/필터/정렬/페이지네이션 (현황: `ShadTable`에 Mock 데이터 하드코딩, 리포지토리/DTO 부재)
|
- 입고/출고/대여: 목록/상세/생성/수정/삭제/복구 + include/필터/정렬/페이지네이션 (현황: `ShadTable`에 Mock 데이터 하드코딩, 리포지토리/DTO 부재)
|
||||||
- 마스터: vendors/products/warehouses/customers/employees/menus/groups/group-permissions(+ 일괄 저장) (현황: 모든 마스터 화면이 `ApiClient` 기반 리포지토리로 CRUD/삭제·복구까지 호출하도록 작성되어 있으나 실제 엔드포인트 유효성 검증 필요)
|
- 마스터: vendors/products/warehouses/customers/employees/menus/groups/group-permissions(+ 일괄 저장) (현황: 모든 마스터 화면이 `ApiClient` 기반 리포지토리로 CRUD/삭제·복구까지 호출하도록 작성되어 있으나 실제 엔드포인트 유효성 검증 필요)
|
||||||
- 결재: approvals(+steps/histories), actions, can-proceed, 템플릿 CRUD/단계 배치 (현황: 템플릿 DTO/리포지토리/컨트롤러를 구현해 CRUD·단계 등록까지 API 연동이 완료됐고 나머지 결재 목록/이력/권한 제어는 진행 중)
|
- 결재: approvals(+steps/histories), actions, can-proceed, 템플릿 CRUD/단계 배치 (현황: 템플릿 DTO/리포지토리/컨트롤러를 구현해 CRUD·단계 등록까지 API 연동이 완료됐고 나머지 결재 목록/이력/권한 제어는 진행 중)
|
||||||
- 우편번호: `GET /zipcodes?...` (현황: 리포지토리/네트워킹 미구현)
|
- 우편번호: `GET /zipcodes?...` (현황: 검색 모달에서 Repository를 통해 `/zipcodes` 호출하고 결과를 표시, 고급 검색 조건은 추후 확장)
|
||||||
- 보고서: 다운로드 엔드포인트 연동(제공 시) (현황: 보고서 화면은 AppLayout 플레이스홀더 상태, API 연동과 다운로드 흐름 미구현)
|
- 보고서: 다운로드 엔드포인트 연동(제공 시) (현황: 보고서 화면은 AppLayout 플레이스홀더 상태, API 연동과 다운로드 흐름 미구현)
|
||||||
|
|
||||||
## 12) 검증/접근성/상호작용
|
## 12) 검증/접근성/상호작용
|
||||||
|
|||||||
71
lib/core/common/utils/json_utils.dart
Normal file
71
lib/core/common/utils/json_utils.dart
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/// JSON 응답 형태를 일관되게 다루기 위한 헬퍼 모음.
|
||||||
|
class JsonUtils {
|
||||||
|
JsonUtils._();
|
||||||
|
|
||||||
|
/// 리스트 응답을 추출한다.
|
||||||
|
///
|
||||||
|
/// - [source]가 List이면 맵 형태의 요소만 반환한다.
|
||||||
|
/// - Map이면 [keys] 순서대로 탐색하며 List/Map을 찾아 리스트로 변환한다.
|
||||||
|
static List<Map<String, dynamic>> extractList(
|
||||||
|
dynamic source, {
|
||||||
|
List<String> keys = const ['items', 'data', 'results'],
|
||||||
|
}) {
|
||||||
|
if (source is List) {
|
||||||
|
return source.whereType<Map<String, dynamic>>().toList(growable: false);
|
||||||
|
}
|
||||||
|
if (source is Map<String, dynamic>) {
|
||||||
|
for (final key in keys) {
|
||||||
|
if (!source.containsKey(key)) continue;
|
||||||
|
final value = source[key];
|
||||||
|
if (value is List) {
|
||||||
|
return value.whereType<Map<String, dynamic>>().toList(
|
||||||
|
growable: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (value is Map<String, dynamic>) {
|
||||||
|
return [value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map 응답을 추출한다. 기본적으로 `data` 키를 우선 확인한다.
|
||||||
|
static Map<String, dynamic> extractMap(
|
||||||
|
dynamic source, {
|
||||||
|
List<String> keys = const ['data'],
|
||||||
|
}) {
|
||||||
|
if (source is Map<String, dynamic>) {
|
||||||
|
for (final key in keys) {
|
||||||
|
final value = source[key];
|
||||||
|
if (value is Map<String, dynamic>) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 맵에서 정수 값을 안전하게 읽는다. 문자열/실수도 허용한다.
|
||||||
|
static int readInt(
|
||||||
|
Map<String, dynamic>? source,
|
||||||
|
String key, {
|
||||||
|
int fallback = 0,
|
||||||
|
}) {
|
||||||
|
if (source == null) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
final value = source[key];
|
||||||
|
if (value is int) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value is double) {
|
||||||
|
return value.round();
|
||||||
|
}
|
||||||
|
if (value is String) {
|
||||||
|
return int.tryParse(value) ?? fallback;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,7 +77,7 @@ final appRouter = GoRouter(
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/masters/customers',
|
path: '/masters/customers',
|
||||||
name: 'masters-customers',
|
name: 'masters-customers',
|
||||||
builder: (context, state) => const CustomerPage(),
|
builder: (context, state) => CustomerPage(routeUri: state.uri),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/masters/users',
|
path: '/masters/users',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||||
|
import 'package:superport_v2/core/common/utils/json_utils.dart';
|
||||||
|
|
||||||
import '../../domain/entities/approval.dart';
|
import '../../domain/entities/approval.dart';
|
||||||
|
|
||||||
@@ -92,16 +93,16 @@ class ApprovalDto {
|
|||||||
);
|
);
|
||||||
|
|
||||||
static PaginatedResult<Approval> parsePaginated(Map<String, dynamic>? json) {
|
static PaginatedResult<Approval> parsePaginated(Map<String, dynamic>? json) {
|
||||||
final items = (json?['items'] as List<dynamic>? ?? [])
|
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
|
||||||
.whereType<Map<String, dynamic>>()
|
final items = rawItems
|
||||||
.map(ApprovalDto.fromJson)
|
.map(ApprovalDto.fromJson)
|
||||||
.map((dto) => dto.toEntity())
|
.map((dto) => dto.toEntity())
|
||||||
.toList();
|
.toList(growable: false);
|
||||||
return PaginatedResult<Approval>(
|
return PaginatedResult<Approval>(
|
||||||
items: items,
|
items: items,
|
||||||
page: json?['page'] as int? ?? 1,
|
page: JsonUtils.readInt(json, 'page', fallback: 1),
|
||||||
pageSize: json?['page_size'] as int? ?? items.length,
|
pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
|
||||||
total: json?['total'] as int? ?? items.length,
|
total: JsonUtils.readInt(json, 'total', fallback: items.length),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||||
|
import 'package:superport_v2/core/common/utils/json_utils.dart';
|
||||||
|
|
||||||
import '../../domain/entities/approval_template.dart';
|
import '../../domain/entities/approval_template.dart';
|
||||||
|
|
||||||
@@ -68,16 +69,16 @@ class ApprovalTemplateDto {
|
|||||||
Map<String, dynamic>? json, {
|
Map<String, dynamic>? json, {
|
||||||
bool includeSteps = false,
|
bool includeSteps = false,
|
||||||
}) {
|
}) {
|
||||||
final items = (json?['items'] as List<dynamic>? ?? [])
|
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
|
||||||
.whereType<Map<String, dynamic>>()
|
final items = rawItems
|
||||||
.map(ApprovalTemplateDto.fromJson)
|
.map(ApprovalTemplateDto.fromJson)
|
||||||
.map((dto) => dto.toEntity(includeSteps: includeSteps))
|
.map((dto) => dto.toEntity(includeSteps: includeSteps))
|
||||||
.toList();
|
.toList(growable: false);
|
||||||
return PaginatedResult<ApprovalTemplate>(
|
return PaginatedResult<ApprovalTemplate>(
|
||||||
items: items,
|
items: items,
|
||||||
page: json?['page'] as int? ?? 1,
|
page: JsonUtils.readInt(json, 'page', fallback: 1),
|
||||||
pageSize: json?['page_size'] as int? ?? items.length,
|
pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
|
||||||
total: json?['total'] as int? ?? items.length,
|
total: JsonUtils.readInt(json, 'total', fallback: items.length),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||||
|
import 'package:superport_v2/core/common/utils/json_utils.dart';
|
||||||
import 'package:superport_v2/features/approvals/data/dtos/approval_dto.dart';
|
import 'package:superport_v2/features/approvals/data/dtos/approval_dto.dart';
|
||||||
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
|
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
|
||||||
|
|
||||||
@@ -91,17 +92,17 @@ class ApprovalHistoryRecordDto {
|
|||||||
static PaginatedResult<ApprovalHistoryRecord> parsePaginated(
|
static PaginatedResult<ApprovalHistoryRecord> parsePaginated(
|
||||||
Map<String, dynamic>? json,
|
Map<String, dynamic>? json,
|
||||||
) {
|
) {
|
||||||
final items = (json?['items'] as List<dynamic>? ?? [])
|
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
|
||||||
.whereType<Map<String, dynamic>>()
|
final items = rawItems
|
||||||
.map(ApprovalHistoryRecordDto.fromJson)
|
.map(ApprovalHistoryRecordDto.fromJson)
|
||||||
.map((dto) => dto.toEntity())
|
.map((dto) => dto.toEntity())
|
||||||
.toList();
|
.toList(growable: false);
|
||||||
|
|
||||||
return PaginatedResult<ApprovalHistoryRecord>(
|
return PaginatedResult<ApprovalHistoryRecord>(
|
||||||
items: items,
|
items: items,
|
||||||
page: json?['page'] as int? ?? 1,
|
page: JsonUtils.readInt(json, 'page', fallback: 1),
|
||||||
pageSize: json?['page_size'] as int? ?? items.length,
|
pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
|
||||||
total: json?['total'] as int? ?? items.length,
|
total: JsonUtils.readInt(json, 'total', fallback: items.length),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||||
|
import 'package:superport_v2/core/common/utils/json_utils.dart';
|
||||||
import 'package:superport_v2/features/approvals/data/dtos/approval_dto.dart';
|
import 'package:superport_v2/features/approvals/data/dtos/approval_dto.dart';
|
||||||
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
|
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
|
||||||
|
|
||||||
@@ -61,17 +62,17 @@ class ApprovalStepRecordDto {
|
|||||||
static PaginatedResult<ApprovalStepRecord> parsePaginated(
|
static PaginatedResult<ApprovalStepRecord> parsePaginated(
|
||||||
Map<String, dynamic>? json,
|
Map<String, dynamic>? json,
|
||||||
) {
|
) {
|
||||||
final items = (json?['items'] as List<dynamic>? ?? [])
|
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
|
||||||
.whereType<Map<String, dynamic>>()
|
final items = rawItems
|
||||||
.map(ApprovalStepRecordDto.fromJson)
|
.map(ApprovalStepRecordDto.fromJson)
|
||||||
.map((dto) => dto.toEntity())
|
.map((dto) => dto.toEntity())
|
||||||
.toList();
|
.toList(growable: false);
|
||||||
|
|
||||||
return PaginatedResult<ApprovalStepRecord>(
|
return PaginatedResult<ApprovalStepRecord>(
|
||||||
items: items,
|
items: items,
|
||||||
page: json?['page'] as int? ?? 1,
|
page: JsonUtils.readInt(json, 'page', fallback: 1),
|
||||||
pageSize: json?['page_size'] as int? ?? items.length,
|
pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
|
||||||
total: json?['total'] as int? ?? items.length,
|
total: JsonUtils.readInt(json, 'total', fallback: items.length),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||||
|
import 'package:superport_v2/core/common/utils/json_utils.dart';
|
||||||
|
|
||||||
import '../../domain/entities/customer.dart';
|
import '../../domain/entities/customer.dart';
|
||||||
|
|
||||||
@@ -93,16 +94,16 @@ class CustomerDto {
|
|||||||
);
|
);
|
||||||
|
|
||||||
static PaginatedResult<Customer> parsePaginated(Map<String, dynamic>? json) {
|
static PaginatedResult<Customer> parsePaginated(Map<String, dynamic>? json) {
|
||||||
final items = (json?['items'] as List<dynamic>? ?? [])
|
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
|
||||||
.whereType<Map<String, dynamic>>()
|
final items = rawItems
|
||||||
.map(CustomerDto.fromJson)
|
.map(CustomerDto.fromJson)
|
||||||
.map((dto) => dto.toEntity())
|
.map((dto) => dto.toEntity())
|
||||||
.toList();
|
.toList(growable: false);
|
||||||
return PaginatedResult<Customer>(
|
return PaginatedResult<Customer>(
|
||||||
items: items,
|
items: items,
|
||||||
page: json?['page'] as int? ?? 1,
|
page: JsonUtils.readInt(json, 'page', fallback: 1),
|
||||||
pageSize: json?['page_size'] as int? ?? items.length,
|
pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
|
||||||
total: json?['total'] as int? ?? items.length,
|
total: JsonUtils.readInt(json, 'total', fallback: items.length),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ enum CustomerTypeFilter { all, partner, general }
|
|||||||
enum CustomerStatusFilter { all, activeOnly, inactiveOnly }
|
enum CustomerStatusFilter { all, activeOnly, inactiveOnly }
|
||||||
|
|
||||||
class CustomerController extends ChangeNotifier {
|
class CustomerController extends ChangeNotifier {
|
||||||
|
static const int defaultPageSize = 20;
|
||||||
|
|
||||||
CustomerController({required CustomerRepository repository})
|
CustomerController({required CustomerRepository repository})
|
||||||
: _repository = repository;
|
: _repository = repository;
|
||||||
|
|
||||||
@@ -20,6 +22,7 @@ class CustomerController extends ChangeNotifier {
|
|||||||
String _query = '';
|
String _query = '';
|
||||||
CustomerTypeFilter _typeFilter = CustomerTypeFilter.all;
|
CustomerTypeFilter _typeFilter = CustomerTypeFilter.all;
|
||||||
CustomerStatusFilter _statusFilter = CustomerStatusFilter.all;
|
CustomerStatusFilter _statusFilter = CustomerStatusFilter.all;
|
||||||
|
int _pageSize = defaultPageSize;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
|
||||||
PaginatedResult<Customer>? get result => _result;
|
PaginatedResult<Customer>? get result => _result;
|
||||||
@@ -28,6 +31,7 @@ class CustomerController extends ChangeNotifier {
|
|||||||
String get query => _query;
|
String get query => _query;
|
||||||
CustomerTypeFilter get typeFilter => _typeFilter;
|
CustomerTypeFilter get typeFilter => _typeFilter;
|
||||||
CustomerStatusFilter get statusFilter => _statusFilter;
|
CustomerStatusFilter get statusFilter => _statusFilter;
|
||||||
|
int get pageSize => _pageSize;
|
||||||
String? get errorMessage => _errorMessage;
|
String? get errorMessage => _errorMessage;
|
||||||
|
|
||||||
Future<void> fetch({int page = 1}) async {
|
Future<void> fetch({int page = 1}) async {
|
||||||
@@ -60,13 +64,16 @@ class CustomerController extends ChangeNotifier {
|
|||||||
|
|
||||||
final response = await _repository.list(
|
final response = await _repository.list(
|
||||||
page: page,
|
page: page,
|
||||||
pageSize: _result?.pageSize ?? 20,
|
pageSize: _pageSize,
|
||||||
query: _query.isEmpty ? null : _query,
|
query: _query.isEmpty ? null : _query,
|
||||||
isPartner: isPartner,
|
isPartner: isPartner,
|
||||||
isGeneral: isGeneral,
|
isGeneral: isGeneral,
|
||||||
isActive: isActive,
|
isActive: isActive,
|
||||||
);
|
);
|
||||||
_result = response;
|
_result = response;
|
||||||
|
if (response.pageSize > 0 && response.pageSize != _pageSize) {
|
||||||
|
_pageSize = response.pageSize;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_errorMessage = e.toString();
|
_errorMessage = e.toString();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -76,20 +83,37 @@ class CustomerController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void updateQuery(String value) {
|
void updateQuery(String value) {
|
||||||
|
if (_query == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
_query = value;
|
_query = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateTypeFilter(CustomerTypeFilter filter) {
|
void updateTypeFilter(CustomerTypeFilter filter) {
|
||||||
|
if (_typeFilter == filter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
_typeFilter = filter;
|
_typeFilter = filter;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateStatusFilter(CustomerStatusFilter filter) {
|
void updateStatusFilter(CustomerStatusFilter filter) {
|
||||||
|
if (_statusFilter == filter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
_statusFilter = filter;
|
_statusFilter = filter;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void updatePageSize(int size) {
|
||||||
|
if (size <= 0 || _pageSize == size) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_pageSize = size;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<Customer?> create(CustomerInput input) async {
|
Future<Customer?> create(CustomerInput input) async {
|
||||||
_setSubmitting(true);
|
_setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
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:go_router/go_router.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
import 'package:superport_v2/core/constants/app_sections.dart';
|
import 'package:superport_v2/core/constants/app_sections.dart';
|
||||||
import 'package:superport_v2/widgets/app_layout.dart';
|
import 'package:superport_v2/widgets/app_layout.dart';
|
||||||
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
||||||
|
import 'package:superport_v2/features/util/postal_search/presentation/models/postal_search_result.dart';
|
||||||
|
import 'package:superport_v2/features/util/postal_search/presentation/widgets/postal_search_dialog.dart';
|
||||||
|
|
||||||
import '../../../../../core/config/environment.dart';
|
import '../../../../../core/config/environment.dart';
|
||||||
import '../../../../../widgets/spec_page.dart';
|
import '../../../../../widgets/spec_page.dart';
|
||||||
@@ -13,7 +16,9 @@ import '../../domain/repositories/customer_repository.dart';
|
|||||||
import '../controllers/customer_controller.dart';
|
import '../controllers/customer_controller.dart';
|
||||||
|
|
||||||
class CustomerPage extends StatelessWidget {
|
class CustomerPage extends StatelessWidget {
|
||||||
const CustomerPage({super.key});
|
const CustomerPage({super.key, required this.routeUri});
|
||||||
|
|
||||||
|
final Uri routeUri;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -76,12 +81,14 @@ class CustomerPage extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return const _CustomerEnabledPage();
|
return _CustomerEnabledPage(routeUri: routeUri);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CustomerEnabledPage extends StatefulWidget {
|
class _CustomerEnabledPage extends StatefulWidget {
|
||||||
const _CustomerEnabledPage();
|
const _CustomerEnabledPage({required this.routeUri});
|
||||||
|
|
||||||
|
final Uri routeUri;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_CustomerEnabledPage> createState() => _CustomerEnabledPageState();
|
State<_CustomerEnabledPage> createState() => _CustomerEnabledPageState();
|
||||||
@@ -92,15 +99,22 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
|||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
final FocusNode _searchFocus = FocusNode();
|
final FocusNode _searchFocus = FocusNode();
|
||||||
String? _lastError;
|
String? _lastError;
|
||||||
|
bool _routeApplied = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_controller = CustomerController(repository: GetIt.I<CustomerRepository>())
|
_controller = CustomerController(repository: GetIt.I<CustomerRepository>())
|
||||||
..addListener(_handleControllerUpdate);
|
..addListener(_handleControllerUpdate);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
}
|
||||||
_controller.fetch();
|
|
||||||
});
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
if (!_routeApplied) {
|
||||||
|
_routeApplied = true;
|
||||||
|
_applyRouteParameters();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleControllerUpdate() {
|
void _handleControllerUpdate() {
|
||||||
@@ -114,6 +128,26 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _applyRouteParameters() {
|
||||||
|
final params = widget.routeUri.queryParameters;
|
||||||
|
final query = params['q'] ?? '';
|
||||||
|
final type = _typeFromParam(params['type']);
|
||||||
|
final status = _statusFromParam(params['status']);
|
||||||
|
final pageSizeParam = int.tryParse(params['page_size'] ?? '');
|
||||||
|
final pageParam = int.tryParse(params['page'] ?? '');
|
||||||
|
|
||||||
|
_searchController.text = query;
|
||||||
|
_controller.updateQuery(query);
|
||||||
|
_controller.updateTypeFilter(type);
|
||||||
|
_controller.updateStatusFilter(status);
|
||||||
|
if (pageSizeParam != null && pageSizeParam > 0) {
|
||||||
|
_controller.updatePageSize(pageSizeParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
final page = pageParam != null && pageParam > 0 ? pageParam : 1;
|
||||||
|
_controller.fetch(page: page);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_controller.removeListener(_handleControllerUpdate);
|
_controller.removeListener(_handleControllerUpdate);
|
||||||
@@ -141,7 +175,8 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
|||||||
? false
|
? false
|
||||||
: (result.page * result.pageSize) < result.total;
|
: (result.page * result.pageSize) < result.total;
|
||||||
|
|
||||||
final showReset = _searchController.text.isNotEmpty ||
|
final showReset =
|
||||||
|
_searchController.text.isNotEmpty ||
|
||||||
_controller.typeFilter != CustomerTypeFilter.all ||
|
_controller.typeFilter != CustomerTypeFilter.all ||
|
||||||
_controller.statusFilter != CustomerStatusFilter.all;
|
_controller.statusFilter != CustomerStatusFilter.all;
|
||||||
|
|
||||||
@@ -232,7 +267,12 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
|||||||
_controller.updateStatusFilter(
|
_controller.updateStatusFilter(
|
||||||
CustomerStatusFilter.all,
|
CustomerStatusFilter.all,
|
||||||
);
|
);
|
||||||
_controller.fetch(page: 1);
|
_updateRoute(
|
||||||
|
page: 1,
|
||||||
|
queryOverride: '',
|
||||||
|
typeOverride: CustomerTypeFilter.all,
|
||||||
|
statusOverride: CustomerStatusFilter.all,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
child: const Text('초기화'),
|
child: const Text('초기화'),
|
||||||
),
|
),
|
||||||
@@ -259,7 +299,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
|||||||
size: ShadButtonSize.sm,
|
size: ShadButtonSize.sm,
|
||||||
onPressed: _controller.isLoading || currentPage <= 1
|
onPressed: _controller.isLoading || currentPage <= 1
|
||||||
? null
|
? null
|
||||||
: () => _controller.fetch(page: currentPage - 1),
|
: () => _goToPage(currentPage - 1),
|
||||||
child: const Text('이전'),
|
child: const Text('이전'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -267,7 +307,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
|||||||
size: ShadButtonSize.sm,
|
size: ShadButtonSize.sm,
|
||||||
onPressed: _controller.isLoading || !hasNext
|
onPressed: _controller.isLoading || !hasNext
|
||||||
? null
|
? null
|
||||||
: () => _controller.fetch(page: currentPage + 1),
|
: () => _goToPage(currentPage + 1),
|
||||||
child: const Text('다음'),
|
child: const Text('다음'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -293,9 +333,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
|||||||
? null
|
? null
|
||||||
: (customer) =>
|
: (customer) =>
|
||||||
_openCustomerForm(context, customer: customer),
|
_openCustomerForm(context, customer: customer),
|
||||||
onDelete: _controller.isSubmitting
|
onDelete: _controller.isSubmitting ? null : _confirmDelete,
|
||||||
? null
|
|
||||||
: _confirmDelete,
|
|
||||||
onRestore: _controller.isSubmitting
|
onRestore: _controller.isSubmitting
|
||||||
? null
|
? null
|
||||||
: _restoreCustomer,
|
: _restoreCustomer,
|
||||||
@@ -307,8 +345,103 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _applyFilters() {
|
void _applyFilters() {
|
||||||
_controller.updateQuery(_searchController.text.trim());
|
final keyword = _searchController.text.trim();
|
||||||
_controller.fetch(page: 1);
|
_controller.updateQuery(keyword);
|
||||||
|
_updateRoute(page: 1, queryOverride: keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _goToPage(int page) {
|
||||||
|
if (page < 1) {
|
||||||
|
page = 1;
|
||||||
|
}
|
||||||
|
_updateRoute(page: page);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateRoute({
|
||||||
|
required int page,
|
||||||
|
String? queryOverride,
|
||||||
|
CustomerTypeFilter? typeOverride,
|
||||||
|
CustomerStatusFilter? statusOverride,
|
||||||
|
int? pageSizeOverride,
|
||||||
|
}) {
|
||||||
|
final query = queryOverride ?? _controller.query;
|
||||||
|
final type = typeOverride ?? _controller.typeFilter;
|
||||||
|
final status = statusOverride ?? _controller.statusFilter;
|
||||||
|
final pageSize = pageSizeOverride ?? _controller.pageSize;
|
||||||
|
|
||||||
|
final params = <String, String>{};
|
||||||
|
if (query.isNotEmpty) {
|
||||||
|
params['q'] = query;
|
||||||
|
}
|
||||||
|
final typeParam = _encodeType(type);
|
||||||
|
if (typeParam != null) {
|
||||||
|
params['type'] = typeParam;
|
||||||
|
}
|
||||||
|
final statusParam = _encodeStatus(status);
|
||||||
|
if (statusParam != null) {
|
||||||
|
params['status'] = statusParam;
|
||||||
|
}
|
||||||
|
if (page > 1) {
|
||||||
|
params['page'] = page.toString();
|
||||||
|
}
|
||||||
|
if (pageSize > 0 && pageSize != CustomerController.defaultPageSize) {
|
||||||
|
params['page_size'] = pageSize.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
final uri = Uri(
|
||||||
|
path: widget.routeUri.path,
|
||||||
|
queryParameters: params.isEmpty ? null : params,
|
||||||
|
);
|
||||||
|
final newLocation = uri.toString();
|
||||||
|
if (widget.routeUri.toString() == newLocation) {
|
||||||
|
_controller.fetch(page: page);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
GoRouter.of(context).go(newLocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomerTypeFilter _typeFromParam(String? value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'partner':
|
||||||
|
return CustomerTypeFilter.partner;
|
||||||
|
case 'general':
|
||||||
|
return CustomerTypeFilter.general;
|
||||||
|
default:
|
||||||
|
return CustomerTypeFilter.all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _encodeType(CustomerTypeFilter filter) {
|
||||||
|
switch (filter) {
|
||||||
|
case CustomerTypeFilter.all:
|
||||||
|
return null;
|
||||||
|
case CustomerTypeFilter.partner:
|
||||||
|
return 'partner';
|
||||||
|
case CustomerTypeFilter.general:
|
||||||
|
return 'general';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomerStatusFilter _statusFromParam(String? value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'active':
|
||||||
|
return CustomerStatusFilter.activeOnly;
|
||||||
|
case 'inactive':
|
||||||
|
return CustomerStatusFilter.inactiveOnly;
|
||||||
|
default:
|
||||||
|
return CustomerStatusFilter.all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _encodeStatus(CustomerStatusFilter filter) {
|
||||||
|
switch (filter) {
|
||||||
|
case CustomerStatusFilter.all:
|
||||||
|
return null;
|
||||||
|
case CustomerStatusFilter.activeOnly:
|
||||||
|
return 'active';
|
||||||
|
case CustomerStatusFilter.inactiveOnly:
|
||||||
|
return 'inactive';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _typeLabel(CustomerTypeFilter filter) {
|
String _typeLabel(CustomerTypeFilter filter) {
|
||||||
@@ -364,6 +497,17 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
|||||||
text: existing?.addressDetail ?? '',
|
text: existing?.addressDetail ?? '',
|
||||||
);
|
);
|
||||||
final noteController = TextEditingController(text: existing?.note ?? '');
|
final noteController = TextEditingController(text: existing?.note ?? '');
|
||||||
|
final existingZipcode = existing?.zipcode;
|
||||||
|
final selectedPostalNotifier = ValueNotifier<PostalSearchResult?>(
|
||||||
|
existingZipcode == null
|
||||||
|
? null
|
||||||
|
: PostalSearchResult(
|
||||||
|
zipcode: existingZipcode.zipcode,
|
||||||
|
sido: existingZipcode.sido,
|
||||||
|
sigungu: existingZipcode.sigungu,
|
||||||
|
roadName: existingZipcode.roadName,
|
||||||
|
),
|
||||||
|
);
|
||||||
final partnerNotifier = ValueNotifier<bool>(existing?.isPartner ?? false);
|
final partnerNotifier = ValueNotifier<bool>(existing?.isPartner ?? false);
|
||||||
final generalNotifier = ValueNotifier<bool>(existing?.isGeneral ?? true);
|
final generalNotifier = ValueNotifier<bool>(existing?.isGeneral ?? true);
|
||||||
final isActiveNotifier = ValueNotifier<bool>(existing?.isActive ?? true);
|
final isActiveNotifier = ValueNotifier<bool>(existing?.isActive ?? true);
|
||||||
@@ -378,6 +522,30 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
|||||||
final theme = ShadTheme.of(dialogContext);
|
final theme = ShadTheme.of(dialogContext);
|
||||||
final materialTheme = Theme.of(dialogContext);
|
final materialTheme = Theme.of(dialogContext);
|
||||||
final navigator = Navigator.of(dialogContext);
|
final navigator = Navigator.of(dialogContext);
|
||||||
|
Future<void> openPostalSearch() async {
|
||||||
|
final keyword = zipcodeController.text.trim();
|
||||||
|
final result = await showPostalSearchDialog(
|
||||||
|
dialogContext,
|
||||||
|
initialKeyword: keyword.isEmpty ? null : keyword,
|
||||||
|
);
|
||||||
|
if (result == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
zipcodeController
|
||||||
|
..text = result.zipcode
|
||||||
|
..selection = TextSelection.collapsed(
|
||||||
|
offset: result.zipcode.length,
|
||||||
|
);
|
||||||
|
selectedPostalNotifier.value = result;
|
||||||
|
if (result.fullAddress.isNotEmpty) {
|
||||||
|
addressController
|
||||||
|
..text = result.fullAddress
|
||||||
|
..selection = TextSelection.collapsed(
|
||||||
|
offset: addressController.text.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Dialog(
|
return Dialog(
|
||||||
insetPadding: const EdgeInsets.all(24),
|
insetPadding: const EdgeInsets.all(24),
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
@@ -648,9 +816,49 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_FormField(
|
_FormField(
|
||||||
label: '우편번호',
|
label: '우편번호',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
child: ShadInput(
|
child: ShadInput(
|
||||||
controller: zipcodeController,
|
controller: zipcodeController,
|
||||||
placeholder: const Text('예: 06000'),
|
placeholder: const Text('예: 06000'),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ShadButton.outline(
|
||||||
|
onPressed: saving.value
|
||||||
|
? null
|
||||||
|
: openPostalSearch,
|
||||||
|
child: const Text('검색'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ValueListenableBuilder<PostalSearchResult?>(
|
||||||
|
valueListenable: selectedPostalNotifier,
|
||||||
|
builder: (_, selection, __) {
|
||||||
|
if (selection == null) {
|
||||||
|
return Text(
|
||||||
|
'검색 버튼을 눌러 주소를 선택하세요.',
|
||||||
|
style: theme.textTheme.small.copyWith(
|
||||||
|
color: theme.colorScheme.mutedForeground,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final fullAddress = selection.fullAddress;
|
||||||
|
return Text(
|
||||||
|
fullAddress.isEmpty
|
||||||
|
? '선택한 우편번호에 주소 정보가 없습니다.'
|
||||||
|
: fullAddress,
|
||||||
|
style: theme.textTheme.small,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -704,6 +912,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
|||||||
zipcodeController.dispose();
|
zipcodeController.dispose();
|
||||||
addressController.dispose();
|
addressController.dispose();
|
||||||
noteController.dispose();
|
noteController.dispose();
|
||||||
|
selectedPostalNotifier.dispose();
|
||||||
partnerNotifier.dispose();
|
partnerNotifier.dispose();
|
||||||
generalNotifier.dispose();
|
generalNotifier.dispose();
|
||||||
isActiveNotifier.dispose();
|
isActiveNotifier.dispose();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||||
|
import 'package:superport_v2/core/common/utils/json_utils.dart';
|
||||||
|
|
||||||
import '../../domain/entities/group.dart';
|
import '../../domain/entities/group.dart';
|
||||||
|
|
||||||
@@ -52,16 +53,16 @@ class GroupDto {
|
|||||||
);
|
);
|
||||||
|
|
||||||
static PaginatedResult<Group> parsePaginated(Map<String, dynamic>? json) {
|
static PaginatedResult<Group> parsePaginated(Map<String, dynamic>? json) {
|
||||||
final items = (json?['items'] as List<dynamic>? ?? [])
|
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
|
||||||
.whereType<Map<String, dynamic>>()
|
final items = rawItems
|
||||||
.map(GroupDto.fromJson)
|
.map(GroupDto.fromJson)
|
||||||
.map((dto) => dto.toEntity())
|
.map((dto) => dto.toEntity())
|
||||||
.toList();
|
.toList(growable: false);
|
||||||
return PaginatedResult<Group>(
|
return PaginatedResult<Group>(
|
||||||
items: items,
|
items: items,
|
||||||
page: json?['page'] as int? ?? 1,
|
page: JsonUtils.readInt(json, 'page', fallback: 1),
|
||||||
pageSize: json?['page_size'] as int? ?? items.length,
|
pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
|
||||||
total: json?['total'] as int? ?? items.length,
|
total: JsonUtils.readInt(json, 'total', fallback: items.length),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||||
|
import 'package:superport_v2/core/common/utils/json_utils.dart';
|
||||||
|
|
||||||
import '../../domain/entities/menu.dart';
|
import '../../domain/entities/menu.dart';
|
||||||
|
|
||||||
@@ -64,16 +65,16 @@ class MenuDto {
|
|||||||
);
|
);
|
||||||
|
|
||||||
static PaginatedResult<MenuItem> parsePaginated(Map<String, dynamic>? json) {
|
static PaginatedResult<MenuItem> parsePaginated(Map<String, dynamic>? json) {
|
||||||
final items = (json?['items'] as List<dynamic>? ?? [])
|
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
|
||||||
.whereType<Map<String, dynamic>>()
|
final items = rawItems
|
||||||
.map(MenuDto.fromJson)
|
.map(MenuDto.fromJson)
|
||||||
.map((dto) => dto.toEntity())
|
.map((dto) => dto.toEntity())
|
||||||
.toList();
|
.toList(growable: false);
|
||||||
return PaginatedResult<MenuItem>(
|
return PaginatedResult<MenuItem>(
|
||||||
items: items,
|
items: items,
|
||||||
page: json?['page'] as int? ?? 1,
|
page: JsonUtils.readInt(json, 'page', fallback: 1),
|
||||||
pageSize: json?['page_size'] as int? ?? items.length,
|
pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
|
||||||
total: json?['total'] as int? ?? items.length,
|
total: JsonUtils.readInt(json, 'total', fallback: items.length),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||||
|
import 'package:superport_v2/core/common/utils/json_utils.dart';
|
||||||
|
|
||||||
import '../../domain/entities/product.dart';
|
import '../../domain/entities/product.dart';
|
||||||
|
|
||||||
@@ -96,16 +97,16 @@ class ProductDto {
|
|||||||
);
|
);
|
||||||
|
|
||||||
static PaginatedResult<Product> parsePaginated(Map<String, dynamic>? json) {
|
static PaginatedResult<Product> parsePaginated(Map<String, dynamic>? json) {
|
||||||
final items = (json?['items'] as List<dynamic>? ?? [])
|
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
|
||||||
.whereType<Map<String, dynamic>>()
|
final items = rawItems
|
||||||
.map(ProductDto.fromJson)
|
.map(ProductDto.fromJson)
|
||||||
.map((dto) => dto.toEntity())
|
.map((dto) => dto.toEntity())
|
||||||
.toList();
|
.toList(growable: false);
|
||||||
return PaginatedResult<Product>(
|
return PaginatedResult<Product>(
|
||||||
items: items,
|
items: items,
|
||||||
page: json?['page'] as int? ?? 1,
|
page: JsonUtils.readInt(json, 'page', fallback: 1),
|
||||||
pageSize: json?['page_size'] as int? ?? items.length,
|
pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
|
||||||
total: json?['total'] as int? ?? items.length,
|
total: JsonUtils.readInt(json, 'total', fallback: items.length),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||||
|
import 'package:superport_v2/core/common/utils/json_utils.dart';
|
||||||
|
|
||||||
import '../../domain/entities/uom.dart';
|
import '../../domain/entities/uom.dart';
|
||||||
|
|
||||||
@@ -48,16 +49,16 @@ class UomDto {
|
|||||||
);
|
);
|
||||||
|
|
||||||
static PaginatedResult<Uom> parsePaginated(Map<String, dynamic>? json) {
|
static PaginatedResult<Uom> parsePaginated(Map<String, dynamic>? json) {
|
||||||
final items = (json?['items'] as List<dynamic>? ?? [])
|
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
|
||||||
.whereType<Map<String, dynamic>>()
|
final items = rawItems
|
||||||
.map(UomDto.fromJson)
|
.map(UomDto.fromJson)
|
||||||
.map((dto) => dto.toEntity())
|
.map((dto) => dto.toEntity())
|
||||||
.toList();
|
.toList(growable: false);
|
||||||
return PaginatedResult<Uom>(
|
return PaginatedResult<Uom>(
|
||||||
items: items,
|
items: items,
|
||||||
page: json?['page'] as int? ?? 1,
|
page: JsonUtils.readInt(json, 'page', fallback: 1),
|
||||||
pageSize: json?['page_size'] as int? ?? items.length,
|
pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
|
||||||
total: json?['total'] as int? ?? items.length,
|
total: JsonUtils.readInt(json, 'total', fallback: items.length),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||||
|
import 'package:superport_v2/core/common/utils/json_utils.dart';
|
||||||
|
|
||||||
import '../../domain/entities/user.dart';
|
import '../../domain/entities/user.dart';
|
||||||
|
|
||||||
@@ -64,16 +65,16 @@ class UserDto {
|
|||||||
static PaginatedResult<UserAccount> parsePaginated(
|
static PaginatedResult<UserAccount> parsePaginated(
|
||||||
Map<String, dynamic>? json,
|
Map<String, dynamic>? json,
|
||||||
) {
|
) {
|
||||||
final items = (json?['items'] as List<dynamic>? ?? [])
|
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
|
||||||
.whereType<Map<String, dynamic>>()
|
final items = rawItems
|
||||||
.map(UserDto.fromJson)
|
.map(UserDto.fromJson)
|
||||||
.map((dto) => dto.toEntity())
|
.map((dto) => dto.toEntity())
|
||||||
.toList();
|
.toList(growable: false);
|
||||||
return PaginatedResult<UserAccount>(
|
return PaginatedResult<UserAccount>(
|
||||||
items: items,
|
items: items,
|
||||||
page: json?['page'] as int? ?? 1,
|
page: JsonUtils.readInt(json, 'page', fallback: 1),
|
||||||
pageSize: json?['page_size'] as int? ?? items.length,
|
pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
|
||||||
total: json?['total'] as int? ?? items.length,
|
total: JsonUtils.readInt(json, 'total', fallback: items.length),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||||
|
import 'package:superport_v2/core/common/utils/json_utils.dart';
|
||||||
|
|
||||||
import '../../domain/entities/vendor.dart';
|
import '../../domain/entities/vendor.dart';
|
||||||
|
|
||||||
@@ -73,16 +74,16 @@ class VendorDto {
|
|||||||
);
|
);
|
||||||
|
|
||||||
static PaginatedResult<Vendor> parsePaginated(Map<String, dynamic>? json) {
|
static PaginatedResult<Vendor> parsePaginated(Map<String, dynamic>? json) {
|
||||||
final items = (json?['items'] as List<dynamic>? ?? [])
|
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
|
||||||
.whereType<Map<String, dynamic>>()
|
final items = rawItems
|
||||||
.map(VendorDto.fromJson)
|
.map(VendorDto.fromJson)
|
||||||
.map((dto) => dto.toEntity())
|
.map((dto) => dto.toEntity())
|
||||||
.toList();
|
.toList(growable: false);
|
||||||
return PaginatedResult<Vendor>(
|
return PaginatedResult<Vendor>(
|
||||||
items: items,
|
items: items,
|
||||||
page: json?['page'] as int? ?? 1,
|
page: JsonUtils.readInt(json, 'page', fallback: 1),
|
||||||
pageSize: json?['page_size'] as int? ?? items.length,
|
pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
|
||||||
total: json?['total'] as int? ?? items.length,
|
total: JsonUtils.readInt(json, 'total', fallback: items.length),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||||
|
import 'package:superport_v2/core/common/utils/json_utils.dart';
|
||||||
|
|
||||||
import '../../domain/entities/warehouse.dart';
|
import '../../domain/entities/warehouse.dart';
|
||||||
|
|
||||||
@@ -75,16 +76,16 @@ class WarehouseDto {
|
|||||||
);
|
);
|
||||||
|
|
||||||
static PaginatedResult<Warehouse> parsePaginated(Map<String, dynamic>? json) {
|
static PaginatedResult<Warehouse> parsePaginated(Map<String, dynamic>? json) {
|
||||||
final items = (json?['items'] as List<dynamic>? ?? [])
|
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
|
||||||
.whereType<Map<String, dynamic>>()
|
final items = rawItems
|
||||||
.map(WarehouseDto.fromJson)
|
.map(WarehouseDto.fromJson)
|
||||||
.map((dto) => dto.toEntity())
|
.map((dto) => dto.toEntity())
|
||||||
.toList();
|
.toList(growable: false);
|
||||||
return PaginatedResult<Warehouse>(
|
return PaginatedResult<Warehouse>(
|
||||||
items: items,
|
items: items,
|
||||||
page: json?['page'] as int? ?? 1,
|
page: JsonUtils.readInt(json, 'page', fallback: 1),
|
||||||
pageSize: json?['page_size'] as int? ?? items.length,
|
pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
|
||||||
total: json?['total'] as int? ?? items.length,
|
total: JsonUtils.readInt(json, 'total', fallback: items.length),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
|||||||
import 'package:superport_v2/core/constants/app_sections.dart';
|
import 'package:superport_v2/core/constants/app_sections.dart';
|
||||||
import 'package:superport_v2/widgets/app_layout.dart';
|
import 'package:superport_v2/widgets/app_layout.dart';
|
||||||
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
||||||
|
import 'package:superport_v2/features/util/postal_search/presentation/models/postal_search_result.dart';
|
||||||
|
import 'package:superport_v2/features/util/postal_search/presentation/widgets/postal_search_dialog.dart';
|
||||||
|
|
||||||
import '../../../../../core/config/environment.dart';
|
import '../../../../../core/config/environment.dart';
|
||||||
import '../../../../../widgets/spec_page.dart';
|
import '../../../../../widgets/spec_page.dart';
|
||||||
@@ -137,7 +139,8 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
|||||||
? false
|
? false
|
||||||
: (result.page * result.pageSize) < result.total;
|
: (result.page * result.pageSize) < result.total;
|
||||||
|
|
||||||
final showReset = _searchController.text.isNotEmpty ||
|
final showReset =
|
||||||
|
_searchController.text.isNotEmpty ||
|
||||||
_controller.statusFilter != WarehouseStatusFilter.all;
|
_controller.statusFilter != WarehouseStatusFilter.all;
|
||||||
|
|
||||||
return AppLayout(
|
return AppLayout(
|
||||||
@@ -267,9 +270,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
|||||||
? null
|
? null
|
||||||
: (warehouse) =>
|
: (warehouse) =>
|
||||||
_openWarehouseForm(context, warehouse: warehouse),
|
_openWarehouseForm(context, warehouse: warehouse),
|
||||||
onDelete: _controller.isSubmitting
|
onDelete: _controller.isSubmitting ? null : _confirmDelete,
|
||||||
? null
|
|
||||||
: _confirmDelete,
|
|
||||||
onRestore: _controller.isSubmitting
|
onRestore: _controller.isSubmitting
|
||||||
? null
|
? null
|
||||||
: _restoreWarehouse,
|
: _restoreWarehouse,
|
||||||
@@ -321,6 +322,17 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
|||||||
text: existing?.addressDetail ?? '',
|
text: existing?.addressDetail ?? '',
|
||||||
);
|
);
|
||||||
final noteController = TextEditingController(text: existing?.note ?? '');
|
final noteController = TextEditingController(text: existing?.note ?? '');
|
||||||
|
final existingZipcode = existing?.zipcode;
|
||||||
|
final selectedPostalNotifier = ValueNotifier<PostalSearchResult?>(
|
||||||
|
existingZipcode == null
|
||||||
|
? null
|
||||||
|
: PostalSearchResult(
|
||||||
|
zipcode: existingZipcode.zipcode,
|
||||||
|
sido: existingZipcode.sido,
|
||||||
|
sigungu: existingZipcode.sigungu,
|
||||||
|
roadName: existingZipcode.roadName,
|
||||||
|
),
|
||||||
|
);
|
||||||
final isActiveNotifier = ValueNotifier<bool>(existing?.isActive ?? true);
|
final isActiveNotifier = ValueNotifier<bool>(existing?.isActive ?? true);
|
||||||
final saving = ValueNotifier<bool>(false);
|
final saving = ValueNotifier<bool>(false);
|
||||||
final codeError = ValueNotifier<String?>(null);
|
final codeError = ValueNotifier<String?>(null);
|
||||||
@@ -332,6 +344,30 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
|||||||
final theme = ShadTheme.of(dialogContext);
|
final theme = ShadTheme.of(dialogContext);
|
||||||
final materialTheme = Theme.of(dialogContext);
|
final materialTheme = Theme.of(dialogContext);
|
||||||
final navigator = Navigator.of(dialogContext);
|
final navigator = Navigator.of(dialogContext);
|
||||||
|
Future<void> openPostalSearch() async {
|
||||||
|
final keyword = zipcodeController.text.trim();
|
||||||
|
final result = await showPostalSearchDialog(
|
||||||
|
dialogContext,
|
||||||
|
initialKeyword: keyword.isEmpty ? null : keyword,
|
||||||
|
);
|
||||||
|
if (result == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
zipcodeController
|
||||||
|
..text = result.zipcode
|
||||||
|
..selection = TextSelection.collapsed(
|
||||||
|
offset: result.zipcode.length,
|
||||||
|
);
|
||||||
|
selectedPostalNotifier.value = result;
|
||||||
|
if (result.fullAddress.isNotEmpty) {
|
||||||
|
addressController
|
||||||
|
..text = result.fullAddress
|
||||||
|
..selection = TextSelection.collapsed(
|
||||||
|
offset: addressController.text.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Dialog(
|
return Dialog(
|
||||||
insetPadding: const EdgeInsets.all(24),
|
insetPadding: const EdgeInsets.all(24),
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
@@ -488,9 +524,49 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_FormField(
|
_FormField(
|
||||||
label: '우편번호',
|
label: '우편번호',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
child: ShadInput(
|
child: ShadInput(
|
||||||
controller: zipcodeController,
|
controller: zipcodeController,
|
||||||
placeholder: const Text('예: 06000'),
|
placeholder: const Text('예: 06000'),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ShadButton.outline(
|
||||||
|
onPressed: saving.value
|
||||||
|
? null
|
||||||
|
: openPostalSearch,
|
||||||
|
child: const Text('검색'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ValueListenableBuilder<PostalSearchResult?>(
|
||||||
|
valueListenable: selectedPostalNotifier,
|
||||||
|
builder: (_, selection, __) {
|
||||||
|
if (selection == null) {
|
||||||
|
return Text(
|
||||||
|
'검색 버튼을 눌러 주소를 선택하세요.',
|
||||||
|
style: theme.textTheme.small.copyWith(
|
||||||
|
color: theme.colorScheme.mutedForeground,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final fullAddress = selection.fullAddress;
|
||||||
|
return Text(
|
||||||
|
fullAddress.isEmpty
|
||||||
|
? '선택한 우편번호에 주소 정보가 없습니다.'
|
||||||
|
: fullAddress,
|
||||||
|
style: theme.textTheme.small,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -554,6 +630,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
|||||||
zipcodeController.dispose();
|
zipcodeController.dispose();
|
||||||
addressController.dispose();
|
addressController.dispose();
|
||||||
noteController.dispose();
|
noteController.dispose();
|
||||||
|
selectedPostalNotifier.dispose();
|
||||||
isActiveNotifier.dispose();
|
isActiveNotifier.dispose();
|
||||||
saving.dispose();
|
saving.dispose();
|
||||||
codeError.dispose();
|
codeError.dispose();
|
||||||
@@ -566,6 +643,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
|||||||
zipcodeController.dispose();
|
zipcodeController.dispose();
|
||||||
addressController.dispose();
|
addressController.dispose();
|
||||||
noteController.dispose();
|
noteController.dispose();
|
||||||
|
selectedPostalNotifier.dispose();
|
||||||
isActiveNotifier.dispose();
|
isActiveNotifier.dispose();
|
||||||
saving.dispose();
|
saving.dispose();
|
||||||
codeError.dispose();
|
codeError.dispose();
|
||||||
|
|||||||
@@ -1,19 +1,169 @@
|
|||||||
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 '../../../../core/constants/app_sections.dart';
|
import 'package:superport_v2/core/constants/app_sections.dart';
|
||||||
import '../../../../widgets/app_layout.dart';
|
import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart';
|
||||||
import '../../../../widgets/components/coming_soon_card.dart';
|
import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart';
|
||||||
import '../../../../widgets/components/filter_bar.dart';
|
import 'package:superport_v2/widgets/app_layout.dart';
|
||||||
|
import 'package:superport_v2/widgets/components/empty_state.dart';
|
||||||
|
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
||||||
|
|
||||||
class ReportingPage extends StatelessWidget {
|
class ReportingPage extends StatefulWidget {
|
||||||
const ReportingPage({super.key});
|
const ReportingPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ReportingPage> createState() => _ReportingPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReportingPageState extends State<ReportingPage> {
|
||||||
|
late final WarehouseRepository _warehouseRepository;
|
||||||
|
final DateFormat _dateFormat = DateFormat('yyyy.MM.dd');
|
||||||
|
|
||||||
|
DateTimeRange? _dateRange;
|
||||||
|
ReportTypeFilter _selectedType = ReportTypeFilter.all;
|
||||||
|
ReportStatusFilter _selectedStatus = ReportStatusFilter.all;
|
||||||
|
WarehouseFilterOption _selectedWarehouse = WarehouseFilterOption.all;
|
||||||
|
|
||||||
|
List<WarehouseFilterOption> _warehouseOptions = const [
|
||||||
|
WarehouseFilterOption.all,
|
||||||
|
];
|
||||||
|
bool _isLoadingWarehouses = false;
|
||||||
|
String? _warehouseError;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_warehouseRepository = GetIt.I<WarehouseRepository>();
|
||||||
|
_loadWarehouses();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 활성 창고 목록을 불러와 드롭다운 옵션을 준비한다.
|
||||||
|
Future<void> _loadWarehouses() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoadingWarehouses = true;
|
||||||
|
_warehouseError = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final result = await _warehouseRepository.list(
|
||||||
|
pageSize: 100,
|
||||||
|
isActive: true,
|
||||||
|
);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final seen = <String>{WarehouseFilterOption.all.cacheKey};
|
||||||
|
final options = <WarehouseFilterOption>[WarehouseFilterOption.all];
|
||||||
|
for (final warehouse in result.items) {
|
||||||
|
final option = WarehouseFilterOption.fromWarehouse(warehouse);
|
||||||
|
if (seen.add(option.cacheKey)) {
|
||||||
|
options.add(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_warehouseOptions = options;
|
||||||
|
WarehouseFilterOption nextSelected = WarehouseFilterOption.all;
|
||||||
|
for (final option in options) {
|
||||||
|
if (option == _selectedWarehouse) {
|
||||||
|
nextSelected = option;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_selectedWarehouse = nextSelected;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_warehouseError = '창고 목록을 불러오지 못했습니다. 잠시 후 다시 시도하세요.';
|
||||||
|
_warehouseOptions = const [WarehouseFilterOption.all];
|
||||||
|
_selectedWarehouse = WarehouseFilterOption.all;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoadingWarehouses = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickDateRange() async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final initialRange =
|
||||||
|
_dateRange ??
|
||||||
|
DateTimeRange(
|
||||||
|
start: DateTime(
|
||||||
|
now.year,
|
||||||
|
now.month,
|
||||||
|
now.day,
|
||||||
|
).subtract(const Duration(days: 6)),
|
||||||
|
end: DateTime(now.year, now.month, now.day),
|
||||||
|
);
|
||||||
|
final picked = await showDateRangePicker(
|
||||||
|
context: context,
|
||||||
|
initialDateRange: initialRange,
|
||||||
|
firstDate: DateTime(now.year - 5),
|
||||||
|
lastDate: DateTime(now.year + 2),
|
||||||
|
helpText: '기간 선택',
|
||||||
|
saveText: '적용',
|
||||||
|
currentDate: now,
|
||||||
|
locale: Localizations.localeOf(context),
|
||||||
|
);
|
||||||
|
if (picked != null && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_dateRange = picked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resetFilters() {
|
||||||
|
setState(() {
|
||||||
|
_dateRange = null;
|
||||||
|
_selectedType = ReportTypeFilter.all;
|
||||||
|
_selectedStatus = ReportStatusFilter.all;
|
||||||
|
_selectedWarehouse = WarehouseFilterOption.all;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _canExport {
|
||||||
|
return _dateRange != null && _selectedType != ReportTypeFilter.all;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _hasCustomFilters {
|
||||||
|
return _dateRange != null ||
|
||||||
|
_selectedType != ReportTypeFilter.all ||
|
||||||
|
_selectedStatus != ReportStatusFilter.all ||
|
||||||
|
_selectedWarehouse != WarehouseFilterOption.all;
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _dateRangeLabel {
|
||||||
|
final range = _dateRange;
|
||||||
|
if (range == null) {
|
||||||
|
return '기간 선택';
|
||||||
|
}
|
||||||
|
return '${_formatDate(range.start)} ~ ${_formatDate(range.end)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(DateTime value) => _dateFormat.format(value);
|
||||||
|
|
||||||
|
void _handleExport(ReportExportFormat format) {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
messenger.clearSnackBars();
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(content: Text('${format.label} 다운로드 연동은 준비 중입니다.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final theme = ShadTheme.of(context);
|
||||||
|
|
||||||
return AppLayout(
|
return AppLayout(
|
||||||
title: '보고서',
|
title: '보고서',
|
||||||
subtitle: '기간, 유형, 창고 조건을 선택해 통합 보고서를 내려받을 수 있도록 준비 중입니다.',
|
subtitle: '조건을 선택해 입·출고와 결재 데이터를 내려받을 수 있도록 준비 중입니다.',
|
||||||
breadcrumbs: const [
|
breadcrumbs: const [
|
||||||
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
|
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
|
||||||
AppBreadcrumbItem(label: '보고', path: '/reports'),
|
AppBreadcrumbItem(label: '보고', path: '/reports'),
|
||||||
@@ -21,12 +171,16 @@ class ReportingPage extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
actions: [
|
actions: [
|
||||||
ShadButton(
|
ShadButton(
|
||||||
onPressed: null,
|
onPressed: _canExport
|
||||||
|
? () => _handleExport(ReportExportFormat.xlsx)
|
||||||
|
: null,
|
||||||
leading: const Icon(LucideIcons.fileDown, size: 16),
|
leading: const Icon(LucideIcons.fileDown, size: 16),
|
||||||
child: const Text('XLSX 다운로드'),
|
child: const Text('XLSX 다운로드'),
|
||||||
),
|
),
|
||||||
ShadButton.outline(
|
ShadButton.outline(
|
||||||
onPressed: null,
|
onPressed: _canExport
|
||||||
|
? () => _handleExport(ReportExportFormat.pdf)
|
||||||
|
: null,
|
||||||
leading: const Icon(LucideIcons.fileText, size: 16),
|
leading: const Icon(LucideIcons.fileText, size: 16),
|
||||||
child: const Text('PDF 다운로드'),
|
child: const Text('PDF 다운로드'),
|
||||||
),
|
),
|
||||||
@@ -34,38 +188,282 @@ class ReportingPage extends StatelessWidget {
|
|||||||
toolbar: FilterBar(
|
toolbar: FilterBar(
|
||||||
children: [
|
children: [
|
||||||
ShadButton.outline(
|
ShadButton.outline(
|
||||||
onPressed: null,
|
onPressed: _pickDateRange,
|
||||||
leading: const Icon(LucideIcons.calendar, size: 16),
|
leading: const Icon(LucideIcons.calendar, size: 16),
|
||||||
child: const Text('기간 선택 (준비중)'),
|
child: Text(_dateRangeLabel),
|
||||||
),
|
),
|
||||||
ShadButton.outline(
|
SizedBox(
|
||||||
onPressed: null,
|
width: 200,
|
||||||
leading: const Icon(LucideIcons.layers, size: 16),
|
child: ShadSelect<ReportTypeFilter>(
|
||||||
child: const Text('유형 선택 (준비중)'),
|
key: ValueKey(_selectedType),
|
||||||
|
initialValue: _selectedType,
|
||||||
|
selectedOptionBuilder: (_, value) => Text(value.label),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value == null) return;
|
||||||
|
setState(() {
|
||||||
|
_selectedType = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
for (final type in ReportTypeFilter.values)
|
||||||
|
ShadOption(value: type, child: Text(type.label)),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
ShadButton.outline(
|
|
||||||
onPressed: null,
|
|
||||||
leading: const Icon(LucideIcons.warehouse, size: 16),
|
|
||||||
child: const Text('창고 선택 (준비중)'),
|
|
||||||
),
|
),
|
||||||
ShadButton.outline(
|
SizedBox(
|
||||||
onPressed: null,
|
width: 220,
|
||||||
leading: const Icon(LucideIcons.badgeCheck, size: 16),
|
child: ShadSelect<WarehouseFilterOption>(
|
||||||
child: const Text('상태 선택 (준비중)'),
|
key: ValueKey(
|
||||||
|
'${_selectedWarehouse.cacheKey}-${_warehouseOptions.length}',
|
||||||
|
),
|
||||||
|
initialValue: _selectedWarehouse,
|
||||||
|
selectedOptionBuilder: (_, value) => Text(value.label),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value == null) return;
|
||||||
|
setState(() {
|
||||||
|
_selectedWarehouse = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
for (final option in _warehouseOptions)
|
||||||
|
ShadOption(value: option, child: Text(option.label)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 200,
|
||||||
|
child: ShadSelect<ReportStatusFilter>(
|
||||||
|
key: ValueKey(_selectedStatus),
|
||||||
|
initialValue: _selectedStatus,
|
||||||
|
selectedOptionBuilder: (_, value) => Text(value.label),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value == null) return;
|
||||||
|
setState(() {
|
||||||
|
_selectedStatus = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
for (final status in ReportStatusFilter.values)
|
||||||
|
ShadOption(value: status, child: Text(status.label)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ShadButton.ghost(
|
||||||
|
onPressed: _hasCustomFilters ? _resetFilters : null,
|
||||||
|
leading: const Icon(LucideIcons.rotateCcw, size: 16),
|
||||||
|
child: const Text('초기화'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (_warehouseError != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
LucideIcons.circleAlert,
|
||||||
|
size: 16,
|
||||||
|
color: theme.colorScheme.destructive,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_warehouseError!,
|
||||||
|
style: theme.textTheme.small.copyWith(
|
||||||
|
color: theme.colorScheme.destructive,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ShadButton.ghost(
|
||||||
|
onPressed: _isLoadingWarehouses ? null : _loadWarehouses,
|
||||||
|
leading: const Icon(LucideIcons.refreshCw, size: 16),
|
||||||
|
child: const Text('재시도'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (_isLoadingWarehouses)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('창고 목록을 불러오는 중입니다...', style: theme.textTheme.small),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ShadCard(
|
||||||
|
title: Text('선택된 조건', style: theme.textTheme.h3),
|
||||||
|
description: Text(
|
||||||
|
'다운로드 전 사용자 조건을 빠르게 검토하세요.',
|
||||||
|
style: theme.textTheme.muted,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_SummaryRow(
|
||||||
|
label: '기간',
|
||||||
|
value: _dateRange == null ? '기간을 선택하세요.' : _dateRangeLabel,
|
||||||
|
),
|
||||||
|
_SummaryRow(label: '유형', value: _selectedType.label),
|
||||||
|
_SummaryRow(label: '창고', value: _selectedWarehouse.label),
|
||||||
|
_SummaryRow(label: '상태', value: _selectedStatus.label),
|
||||||
|
if (!_canExport)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12),
|
||||||
|
child: Text(
|
||||||
|
'기간과 유형을 선택하면 다운로드 버튼이 활성화됩니다.',
|
||||||
|
style: theme.textTheme.small.copyWith(
|
||||||
|
color: theme.colorScheme.mutedForeground,
|
||||||
),
|
),
|
||||||
const ShadBadge(
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
||||||
child: Text('API 스펙 정리 후 필터가 활성화됩니다.'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: const ComingSoonCard(
|
),
|
||||||
title: '보고서 화면 구현 준비 중',
|
const SizedBox(height: 24),
|
||||||
description: '입·출고/결재 데이터를 조건별로 조회하고 다운로드할 수 있는 UI를 설계 중입니다.',
|
ShadCard(
|
||||||
items: ['조건별 보고서 템플릿 매핑', '다운로드 진행 상태 표시 및 실패 처리', '즐겨찾는 조건 저장/불러오기'],
|
title: Text('보고서 미리보기', style: theme.textTheme.h3),
|
||||||
|
description: Text(
|
||||||
|
'조건을 적용하면 다운로드 진행 상태와 결과 테이블이 이 영역에 표시됩니다.',
|
||||||
|
style: theme.textTheme.muted,
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 240,
|
||||||
|
child: EmptyState(
|
||||||
|
icon: LucideIcons.chartBar,
|
||||||
|
message: '필터를 선택하고 다운로드하면 결과 미리보기가 제공됩니다.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _SummaryRow extends StatelessWidget {
|
||||||
|
const _SummaryRow({required this.label, required this.value});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = ShadTheme.of(context);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 72,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: theme.textTheme.small.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: Text(value, style: theme.textTheme.p)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReportTypeFilter { all, inbound, outbound, rental, approval }
|
||||||
|
|
||||||
|
extension ReportTypeFilterX on ReportTypeFilter {
|
||||||
|
String get label {
|
||||||
|
switch (this) {
|
||||||
|
case ReportTypeFilter.all:
|
||||||
|
return '전체 유형';
|
||||||
|
case ReportTypeFilter.inbound:
|
||||||
|
return '입고';
|
||||||
|
case ReportTypeFilter.outbound:
|
||||||
|
return '출고';
|
||||||
|
case ReportTypeFilter.rental:
|
||||||
|
return '대여';
|
||||||
|
case ReportTypeFilter.approval:
|
||||||
|
return '결재';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReportStatusFilter { all, inProgress, completed, cancelled }
|
||||||
|
|
||||||
|
extension ReportStatusFilterX on ReportStatusFilter {
|
||||||
|
String get label {
|
||||||
|
switch (this) {
|
||||||
|
case ReportStatusFilter.all:
|
||||||
|
return '전체 상태';
|
||||||
|
case ReportStatusFilter.inProgress:
|
||||||
|
return '진행중';
|
||||||
|
case ReportStatusFilter.completed:
|
||||||
|
return '완료';
|
||||||
|
case ReportStatusFilter.cancelled:
|
||||||
|
return '취소';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReportExportFormat { xlsx, pdf }
|
||||||
|
|
||||||
|
extension ReportExportFormatX on ReportExportFormat {
|
||||||
|
String get label => switch (this) {
|
||||||
|
ReportExportFormat.xlsx => 'XLSX',
|
||||||
|
ReportExportFormat.pdf => 'PDF',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class WarehouseFilterOption {
|
||||||
|
const WarehouseFilterOption({this.id, required this.label});
|
||||||
|
|
||||||
|
final int? id;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
static const WarehouseFilterOption all = WarehouseFilterOption(
|
||||||
|
id: null,
|
||||||
|
label: '전체 창고',
|
||||||
|
);
|
||||||
|
|
||||||
|
factory WarehouseFilterOption.fromWarehouse(Warehouse warehouse) {
|
||||||
|
final id = warehouse.id;
|
||||||
|
final name = warehouse.warehouseName;
|
||||||
|
final code = warehouse.warehouseCode;
|
||||||
|
return WarehouseFilterOption(
|
||||||
|
id: id,
|
||||||
|
label: id == null ? name : '$name ($code)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get cacheKey => id?.toString() ?? label;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (other is! WarehouseFilterOption) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (id != null && other.id != null) {
|
||||||
|
return id == other.id;
|
||||||
|
}
|
||||||
|
return label == other.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => cacheKey.hashCode;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import 'package:superport_v2/core/common/utils/json_utils.dart';
|
||||||
|
|
||||||
|
import '../../domain/entities/postal_code.dart';
|
||||||
|
|
||||||
|
/// 우편번호 검색 API 응답을 표현하는 DTO.
|
||||||
|
class PostalCodeDto {
|
||||||
|
PostalCodeDto({
|
||||||
|
required this.zipcode,
|
||||||
|
this.sido,
|
||||||
|
this.sigungu,
|
||||||
|
this.roadName,
|
||||||
|
this.buildingMainNo,
|
||||||
|
this.buildingSubNo,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String zipcode;
|
||||||
|
final String? sido;
|
||||||
|
final String? sigungu;
|
||||||
|
final String? roadName;
|
||||||
|
final int? buildingMainNo;
|
||||||
|
final int? buildingSubNo;
|
||||||
|
|
||||||
|
factory PostalCodeDto.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PostalCodeDto(
|
||||||
|
zipcode: json['zipcode'] as String,
|
||||||
|
sido: json['sido'] as String?,
|
||||||
|
sigungu: json['sigungu'] as String?,
|
||||||
|
roadName: json['road_name'] as String?,
|
||||||
|
buildingMainNo: _parseInt(json['building_main_no']),
|
||||||
|
buildingSubNo: _parseInt(json['building_sub_no']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PostalCode toEntity() {
|
||||||
|
return PostalCode(
|
||||||
|
zipcode: zipcode,
|
||||||
|
sido: sido,
|
||||||
|
sigungu: sigungu,
|
||||||
|
roadName: roadName,
|
||||||
|
buildingMainNo: buildingMainNo,
|
||||||
|
buildingSubNo: buildingSubNo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<PostalCode> fromResponse(dynamic data) {
|
||||||
|
final items = JsonUtils.extractList(data, keys: const ['items', 'data']);
|
||||||
|
if (items.isEmpty) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
.map(PostalCodeDto.fromJson)
|
||||||
|
.map((dto) => dto.toEntity())
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _parseInt(Object? value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value is int) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value is String) {
|
||||||
|
return int.tryParse(value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import 'package:superport_v2/core/network/api_client.dart';
|
||||||
|
|
||||||
|
import '../../domain/entities/postal_code.dart';
|
||||||
|
import '../../domain/repositories/postal_search_repository.dart';
|
||||||
|
import '../dtos/postal_code_dto.dart';
|
||||||
|
|
||||||
|
/// 우편번호 검색 API를 호출하는 원격 저장소 구현체.
|
||||||
|
class PostalSearchRepositoryRemote implements PostalSearchRepository {
|
||||||
|
PostalSearchRepositoryRemote({required ApiClient apiClient})
|
||||||
|
: _api = apiClient;
|
||||||
|
|
||||||
|
final ApiClient _api;
|
||||||
|
|
||||||
|
static const _path = '/zipcodes';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<PostalCode>> search({
|
||||||
|
required String keyword,
|
||||||
|
int limit = 20,
|
||||||
|
}) async {
|
||||||
|
final trimmed = keyword.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await _api.get<dynamic>(
|
||||||
|
_path,
|
||||||
|
query: {
|
||||||
|
'zipcode': trimmed,
|
||||||
|
'road_name': trimmed,
|
||||||
|
'q': trimmed,
|
||||||
|
'page_size': limit,
|
||||||
|
},
|
||||||
|
options: Options(responseType: ResponseType.json),
|
||||||
|
);
|
||||||
|
|
||||||
|
return PostalCodeDto.fromResponse(response.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/// 우편번호 검색 결과의 도메인 모델.
|
||||||
|
///
|
||||||
|
/// - `zipcode`: 5자리 우편번호.
|
||||||
|
/// - `sido`/`sigungu`: 시도 및 시군구 행정 구역.
|
||||||
|
/// - `roadName`: 도로명 주소 구성 요소.
|
||||||
|
/// - `buildingMainNo`/`buildingSubNo`: 건물 번호 본번/부번(없으면 null).
|
||||||
|
class PostalCode {
|
||||||
|
PostalCode({
|
||||||
|
required this.zipcode,
|
||||||
|
this.sido,
|
||||||
|
this.sigungu,
|
||||||
|
this.roadName,
|
||||||
|
this.buildingMainNo,
|
||||||
|
this.buildingSubNo,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String zipcode;
|
||||||
|
final String? sido;
|
||||||
|
final String? sigungu;
|
||||||
|
final String? roadName;
|
||||||
|
final int? buildingMainNo;
|
||||||
|
final int? buildingSubNo;
|
||||||
|
|
||||||
|
/// 건물 번호 문자열을 반환한다. 본번만 존재하면 해당 값만 반환한다.
|
||||||
|
String get buildingNumber {
|
||||||
|
final main = buildingMainNo;
|
||||||
|
final sub = buildingSubNo;
|
||||||
|
if (main == null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (sub == null || sub == 0) {
|
||||||
|
return '$main';
|
||||||
|
}
|
||||||
|
return '$main-$sub';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import '../entities/postal_code.dart';
|
||||||
|
|
||||||
|
/// 우편번호 검색 기능을 추상화한 저장소 인터페이스.
|
||||||
|
abstract class PostalSearchRepository {
|
||||||
|
/// 키워드를 기반으로 우편번호 목록을 검색한다.
|
||||||
|
///
|
||||||
|
/// [keyword]는 우편번호/도로명/건물번호 중 하나의 문자열을 전달한다.
|
||||||
|
/// [limit]을 지정하면 최대 반환 건수를 제한한다.
|
||||||
|
Future<List<PostalCode>> search({required String keyword, int limit = 20});
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import '../../domain/entities/postal_code.dart';
|
||||||
|
|
||||||
|
/// 우편번호 검색 결과를 표현하는 모델.
|
||||||
|
class PostalSearchResult {
|
||||||
|
PostalSearchResult({
|
||||||
|
required this.zipcode,
|
||||||
|
this.sido,
|
||||||
|
this.sigungu,
|
||||||
|
this.roadName,
|
||||||
|
this.buildingNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String zipcode;
|
||||||
|
final String? sido;
|
||||||
|
final String? sigungu;
|
||||||
|
final String? roadName;
|
||||||
|
final String? buildingNumber;
|
||||||
|
|
||||||
|
factory PostalSearchResult.fromPostalCode(PostalCode postalCode) {
|
||||||
|
final buildingValue = postalCode.buildingNumber;
|
||||||
|
return PostalSearchResult(
|
||||||
|
zipcode: postalCode.zipcode,
|
||||||
|
sido: postalCode.sido,
|
||||||
|
sigungu: postalCode.sigungu,
|
||||||
|
roadName: postalCode.roadName,
|
||||||
|
buildingNumber: buildingValue.isEmpty ? null : buildingValue,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 주소 구성요소를 공백으로 이어 붙인 표시 문자열.
|
||||||
|
String get fullAddress {
|
||||||
|
final segments = <String>[];
|
||||||
|
final sidoValue = sido;
|
||||||
|
if (sidoValue != null && sidoValue.isNotEmpty) {
|
||||||
|
segments.add(sidoValue);
|
||||||
|
}
|
||||||
|
final sigunguValue = sigungu;
|
||||||
|
if (sigunguValue != null && sigunguValue.isNotEmpty) {
|
||||||
|
segments.add(sigunguValue);
|
||||||
|
}
|
||||||
|
final roadNameValue = roadName;
|
||||||
|
if (roadNameValue != null && roadNameValue.isNotEmpty) {
|
||||||
|
segments.add(roadNameValue);
|
||||||
|
}
|
||||||
|
final buildingNumberValue = buildingNumber;
|
||||||
|
if (buildingNumberValue != null && buildingNumberValue.isNotEmpty) {
|
||||||
|
segments.add(buildingNumberValue);
|
||||||
|
}
|
||||||
|
return segments.join(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
|
import 'package:superport_v2/widgets/components/superport_table.dart';
|
||||||
|
|
||||||
|
import '../../domain/entities/postal_code.dart';
|
||||||
|
import '../../domain/repositories/postal_search_repository.dart';
|
||||||
|
import '../models/postal_search_result.dart';
|
||||||
|
|
||||||
|
typedef PostalSearchFetcher =
|
||||||
|
Future<List<PostalSearchResult>> Function(String keyword);
|
||||||
|
|
||||||
|
/// 우편번호 검색 모달을 노출한다.
|
||||||
|
Future<PostalSearchResult?> showPostalSearchDialog(
|
||||||
|
BuildContext context, {
|
||||||
|
PostalSearchFetcher? fetcher,
|
||||||
|
String? initialKeyword,
|
||||||
|
}) {
|
||||||
|
return showDialog<PostalSearchResult>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return _PostalSearchDialog(
|
||||||
|
fetcher: fetcher,
|
||||||
|
initialKeyword: initialKeyword,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PostalSearchDialog extends StatefulWidget {
|
||||||
|
const _PostalSearchDialog({required this.fetcher, this.initialKeyword});
|
||||||
|
|
||||||
|
final PostalSearchFetcher? fetcher;
|
||||||
|
final String? initialKeyword;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_PostalSearchDialog> createState() => _PostalSearchDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PostalSearchDialogState extends State<_PostalSearchDialog> {
|
||||||
|
late final TextEditingController _keywordController;
|
||||||
|
final FocusNode _keywordFocus = FocusNode();
|
||||||
|
List<PostalSearchResult> _results = const [];
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _hasSearched = false;
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_keywordController = TextEditingController(text: widget.initialKeyword);
|
||||||
|
if (_keywordController.text.isNotEmpty) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_performSearch();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_keywordController.dispose();
|
||||||
|
_keywordFocus.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _performSearch() async {
|
||||||
|
final keyword = _keywordController.text.trim();
|
||||||
|
if (keyword.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_results = const [];
|
||||||
|
_errorMessage = null;
|
||||||
|
_hasSearched = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final fetcher = _resolveFetcher();
|
||||||
|
if (fetcher == null) {
|
||||||
|
setState(() {
|
||||||
|
_results = const [];
|
||||||
|
_errorMessage = '검색 기능이 아직 연결되지 않았습니다.';
|
||||||
|
_hasSearched = true;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
_hasSearched = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final items = await fetcher(keyword);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_results = items;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_results = const [];
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PostalSearchFetcher? _resolveFetcher() {
|
||||||
|
final customFetcher = widget.fetcher;
|
||||||
|
if (customFetcher != null) {
|
||||||
|
return customFetcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
final container = GetIt.instance;
|
||||||
|
if (!container.isRegistered<PostalSearchRepository>()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (keyword) async {
|
||||||
|
final repository = container<PostalSearchRepository>();
|
||||||
|
final List<PostalCode> results = await repository.search(
|
||||||
|
keyword: keyword,
|
||||||
|
limit: 50,
|
||||||
|
);
|
||||||
|
return results
|
||||||
|
.map(PostalSearchResult.fromPostalCode)
|
||||||
|
.toList(growable: false);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = ShadTheme.of(context);
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
|
||||||
|
return Dialog(
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
insetPadding: const EdgeInsets.all(24),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 760, maxHeight: 600),
|
||||||
|
child: ShadCard(
|
||||||
|
title: Text('우편번호 검색', style: theme.textTheme.h3),
|
||||||
|
description: Text(
|
||||||
|
'도로명, 건물번호, 우편번호를 입력하면 검색 결과에서 직접 선택할 수 있습니다.',
|
||||||
|
style: theme.textTheme.muted,
|
||||||
|
),
|
||||||
|
footer: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
ShadButton.ghost(
|
||||||
|
onPressed: () => navigator.pop(),
|
||||||
|
child: const Text('닫기'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.max,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ShadInput(
|
||||||
|
controller: _keywordController,
|
||||||
|
focusNode: _keywordFocus,
|
||||||
|
placeholder: const Text('도로명, 건물번호, 우편번호를 입력'),
|
||||||
|
leading: const Icon(LucideIcons.search, size: 16),
|
||||||
|
onSubmitted: (_) => _performSearch(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
ShadButton(
|
||||||
|
onPressed: _isLoading ? null : _performSearch,
|
||||||
|
child: _isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text('검색'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(child: _buildResultArea(theme, navigator)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildResultArea(ShadThemeData theme, NavigatorState navigator) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_errorMessage != null) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Text(
|
||||||
|
_errorMessage!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.small,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_results.isEmpty) {
|
||||||
|
final label = _hasSearched
|
||||||
|
? '검색 결과가 없습니다. 다른 키워드로 시도해 주세요.'
|
||||||
|
: '검색어를 입력한 뒤 엔터 또는 검색 버튼을 눌러 주세요.';
|
||||||
|
return Center(child: Text(label, style: theme.textTheme.muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('검색 결과 ${_results.length}건', style: theme.textTheme.muted),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Expanded(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: theme.colorScheme.border),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: SuperportTable(
|
||||||
|
columns: const [
|
||||||
|
Text('우편번호'),
|
||||||
|
Text('시도'),
|
||||||
|
Text('시군구'),
|
||||||
|
Text('도로명'),
|
||||||
|
Text('건물번호'),
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
for (final item in _results)
|
||||||
|
[
|
||||||
|
Text(item.zipcode),
|
||||||
|
Text(item.sido ?? '-'),
|
||||||
|
Text(item.sigungu ?? '-'),
|
||||||
|
Text(item.roadName ?? '-'),
|
||||||
|
Text(item.buildingNumber ?? '-'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
onRowTap: (index) {
|
||||||
|
navigator.pop(_results[index]);
|
||||||
|
},
|
||||||
|
emptyLabel: '검색 결과가 없습니다.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('행을 클릭하면 선택한 주소가 부모 폼에 채워집니다.', style: theme.textTheme.small),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ import 'features/approvals/step/data/repositories/approval_step_repository_remot
|
|||||||
import 'features/approvals/domain/repositories/approval_repository.dart';
|
import 'features/approvals/domain/repositories/approval_repository.dart';
|
||||||
import 'features/approvals/domain/repositories/approval_template_repository.dart';
|
import 'features/approvals/domain/repositories/approval_template_repository.dart';
|
||||||
import 'features/approvals/step/domain/repositories/approval_step_repository.dart';
|
import 'features/approvals/step/domain/repositories/approval_step_repository.dart';
|
||||||
|
import 'features/util/postal_search/data/repositories/postal_search_repository_remote.dart';
|
||||||
|
import 'features/util/postal_search/domain/repositories/postal_search_repository.dart';
|
||||||
|
|
||||||
/// 전역 DI 컨테이너
|
/// 전역 DI 컨테이너
|
||||||
final GetIt sl = GetIt.instance;
|
final GetIt sl = GetIt.instance;
|
||||||
@@ -113,4 +115,8 @@ Future<void> initInjection({
|
|||||||
sl.registerLazySingleton<ApprovalHistoryRepository>(
|
sl.registerLazySingleton<ApprovalHistoryRepository>(
|
||||||
() => ApprovalHistoryRepositoryRemote(apiClient: sl<ApiClient>()),
|
() => ApprovalHistoryRepositoryRemote(apiClient: sl<ApiClient>()),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
sl.registerLazySingleton<PostalSearchRepository>(
|
||||||
|
() => PostalSearchRepositoryRemote(apiClient: sl<ApiClient>()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ void main() {
|
|||||||
testWidgets('플래그 Off 시 스펙 화면 유지', (tester) async {
|
testWidgets('플래그 Off 시 스펙 화면 유지', (tester) async {
|
||||||
dotenv.testLoad(fileInput: 'FEATURE_CUSTOMERS_ENABLED=false\n');
|
dotenv.testLoad(fileInput: 'FEATURE_CUSTOMERS_ENABLED=false\n');
|
||||||
|
|
||||||
await tester.pumpWidget(_buildApp(const CustomerPage()));
|
await tester.pumpWidget(
|
||||||
|
_buildApp(CustomerPage(routeUri: Uri(path: '/masters/customers'))),
|
||||||
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.text('회사(고객사) 관리'), findsOneWidget);
|
expect(find.text('회사(고객사) 관리'), findsOneWidget);
|
||||||
@@ -85,7 +87,9 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(_buildApp(const CustomerPage()));
|
await tester.pumpWidget(
|
||||||
|
_buildApp(CustomerPage(routeUri: Uri(path: '/masters/customers'))),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('C-001'), findsOneWidget);
|
expect(find.text('C-001'), findsOneWidget);
|
||||||
@@ -120,7 +124,9 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(_buildApp(const CustomerPage()));
|
await tester.pumpWidget(
|
||||||
|
_buildApp(CustomerPage(routeUri: Uri(path: '/masters/customers'))),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('신규 등록'));
|
await tester.tap(find.text('신규 등록'));
|
||||||
@@ -181,7 +187,9 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
await tester.pumpWidget(_buildApp(const CustomerPage()));
|
await tester.pumpWidget(
|
||||||
|
_buildApp(CustomerPage(routeUri: Uri(path: '/masters/customers'))),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('신규 등록'));
|
await tester.tap(find.text('신규 등록'));
|
||||||
|
|||||||
Reference in New Issue
Block a user