결재 API 계약 보완 및 테스트 정리
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
# 백엔드 수정 요청서 (2024-08-XX 갱신)
|
# 백엔드 수정 요청서 (2025-10-16 갱신)
|
||||||
|
|
||||||
## 1. 배경
|
## 1. 배경
|
||||||
- Flutter 프론트엔드(`superport_v2`)가 최신 백엔드(`superport_api_v2`)와 실연동을 준비하면서, 일부 엔드포인트가 미구현이거나 응답 스키마가 불완전해 화면 기능을 마무리하기 어렵다.
|
- Flutter 프론트엔드(`superport_v2`)가 최신 백엔드(`superport_api_v2`)와 실연동을 준비하면서, 일부 엔드포인트가 미구현이거나 응답 스키마가 불완전해 화면 기능을 마무리하기 어렵다.
|
||||||
@@ -18,7 +18,9 @@
|
|||||||
- `GET /api/v1/reports/transactions/export`
|
- `GET /api/v1/reports/transactions/export`
|
||||||
- `GET /api/v1/reports/approvals/export`
|
- `GET /api/v1/reports/approvals/export`
|
||||||
- 요구 사항:
|
- 요구 사항:
|
||||||
- 쿼리 파라미터: `from`, `to`(ISO 8601), `format`(xlsx|pdf), `type_id`, `status_id`, `warehouse_id` 등 프론트에서 사용하는 필터 수용.
|
- 쿼리 파라미터:
|
||||||
|
- 트랜잭션: `from`, `to`(yyyy-MM-dd), `format`(xlsx|pdf), `transaction_status_id`, `approval_status_id`, `requested_by_id`
|
||||||
|
- 결재: `from`, `to`(ISO 8601), `format`(xlsx|pdf), `transaction_status_id`, `approval_status_id`, `requested_by_id`
|
||||||
- 응답:
|
- 응답:
|
||||||
- 파일 다운로드(바이트 스트림) 또는 `data.download_url`, `data.filename`, `data.mime_type`, `data.expires_at`을 포함한 JSON.
|
- 파일 다운로드(바이트 스트림) 또는 `data.download_url`, `data.filename`, `data.mime_type`, `data.expires_at`을 포함한 JSON.
|
||||||
- 인증/권한 정책 확정 후 문서화.
|
- 인증/권한 정책 확정 후 문서화.
|
||||||
@@ -42,6 +44,14 @@
|
|||||||
- 위 변경 사항이 반영되면 `stock_approval_system_api_v4.md`를 업데이트하고, 각 엔드포인트 예제 응답을 최신 상태로 반영한다.
|
- 위 변경 사항이 반영되면 `stock_approval_system_api_v4.md`를 업데이트하고, 각 엔드포인트 예제 응답을 최신 상태로 반영한다.
|
||||||
- 회귀 테스트(`cargo test` + 통합 시나리오 스크립트)가 변경된 계약을 검증하도록 보강한다.
|
- 회귀 테스트(`cargo test` + 통합 시나리오 스크립트)가 변경된 계약을 검증하도록 보강한다.
|
||||||
|
|
||||||
|
### 3.5 결재 생성/수정 API 정합성
|
||||||
|
- `POST /api/v1/approvals`가 다음 요청 바디를 수용하도록 구현 필요:
|
||||||
|
- 필수: `transaction_id`, `approval_no`, `approval_status_id`, `requested_by_id`
|
||||||
|
- 선택: `note`
|
||||||
|
- 응답에는 갱신된 결재 요약(`data.approval`)과 현재 단계/상태 정보가 포함돼야 프론트가 즉시 리스트를 재사용할 수 있다.
|
||||||
|
- `PATCH /api/v1/approvals/{id}`는 본문에 `id`를 요구하고 `approval_status_id`, `note` 변경을 허용해야 한다. 응답은 최신 결재 정보를 반환해 상세 패널을 재조회 없이 갱신할 수 있도록 한다.
|
||||||
|
- 결재번호(`approval_no`) 중복/포맷 검증과 기본 상태(예: 대기) 자동 할당 규칙을 API 스펙에 명시해 달라.
|
||||||
|
|
||||||
## 4. 수용 기준
|
## 4. 수용 기준
|
||||||
- 상기 엔드포인트가 모두 구현되고, 요청/응답이 문서와 일치해야 한다.
|
- 상기 엔드포인트가 모두 구현되고, 요청/응답이 문서와 일치해야 한다.
|
||||||
- 레거시 응답(204)에서 JSON 반환으로 변경될 경우, 클라이언트가 기대하는 키(`data.approval`, `data.steps` 등)를 포함해야 한다.
|
- 레거시 응답(204)에서 JSON 반환으로 변경될 경우, 클라이언트가 기대하는 키(`data.approval`, `data.steps` 등)를 포함해야 한다.
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
|
|||||||
|
|
||||||
/// 새로운 결재를 생성한다.
|
/// 새로운 결재를 생성한다.
|
||||||
@override
|
@override
|
||||||
Future<Approval> create(ApprovalInput input) async {
|
Future<Approval> create(ApprovalCreateInput input) async {
|
||||||
final response = await _api.post<Map<String, dynamic>>(
|
final response = await _api.post<Map<String, dynamic>>(
|
||||||
_basePath,
|
_basePath,
|
||||||
data: input.toPayload(),
|
data: input.toPayload(),
|
||||||
@@ -151,9 +151,9 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
|
|||||||
|
|
||||||
/// 결재 기본 정보를 수정한다.
|
/// 결재 기본 정보를 수정한다.
|
||||||
@override
|
@override
|
||||||
Future<Approval> update(int id, ApprovalInput input) async {
|
Future<Approval> update(ApprovalUpdateInput input) async {
|
||||||
final response = await _api.patch<Map<String, dynamic>>(
|
final response = await _api.patch<Map<String, dynamic>>(
|
||||||
'$_basePath/$id',
|
'$_basePath/${input.id}',
|
||||||
data: input.toPayload(),
|
data: input.toPayload(),
|
||||||
options: Options(responseType: ResponseType.json),
|
options: Options(responseType: ResponseType.json),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -198,14 +198,53 @@ extension ApprovalStepActionTypeX on ApprovalStepActionType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 결재 생성 입력 모델
|
/// 결재 생성 입력 모델
|
||||||
class ApprovalInput {
|
/// 결재 신규 생성 입력 모델
|
||||||
ApprovalInput({required this.transactionId, this.note});
|
///
|
||||||
|
/// - 트랜잭션, 결재번호, 상태, 상신자 정보를 백엔드 계약에 맞춰 전달한다.
|
||||||
|
class ApprovalCreateInput {
|
||||||
|
ApprovalCreateInput({
|
||||||
|
required this.transactionId,
|
||||||
|
required this.approvalNo,
|
||||||
|
required this.approvalStatusId,
|
||||||
|
required this.requestedById,
|
||||||
|
this.note,
|
||||||
|
});
|
||||||
|
|
||||||
final int transactionId;
|
final int transactionId;
|
||||||
|
final String approvalNo;
|
||||||
|
final int approvalStatusId;
|
||||||
|
final int requestedById;
|
||||||
final String? note;
|
final String? note;
|
||||||
|
|
||||||
Map<String, dynamic> toPayload() {
|
Map<String, dynamic> toPayload() {
|
||||||
return {'transaction_id': transactionId, 'note': note};
|
final trimmedNote = note?.trim();
|
||||||
|
return {
|
||||||
|
'transaction_id': transactionId,
|
||||||
|
'approval_no': approvalNo,
|
||||||
|
'approval_status_id': approvalStatusId,
|
||||||
|
'requested_by_id': requestedById,
|
||||||
|
if (trimmedNote != null && trimmedNote.isNotEmpty) 'note': trimmedNote,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 결재 기본 정보 수정 입력 모델
|
||||||
|
///
|
||||||
|
/// - 상태/비고 변경 시 결재 식별자를 포함해 패치를 수행한다.
|
||||||
|
class ApprovalUpdateInput {
|
||||||
|
ApprovalUpdateInput({required this.id, this.approvalStatusId, this.note});
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final int? approvalStatusId;
|
||||||
|
final String? note;
|
||||||
|
|
||||||
|
Map<String, dynamic> toPayload() {
|
||||||
|
final trimmedNote = note?.trim();
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
if (approvalStatusId != null) 'approval_status_id': approvalStatusId,
|
||||||
|
if (trimmedNote != null && trimmedNote.isNotEmpty) 'note': trimmedNote,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,10 +38,10 @@ abstract class ApprovalRepository {
|
|||||||
Future<ApprovalProceedStatus> canProceed(int id);
|
Future<ApprovalProceedStatus> canProceed(int id);
|
||||||
|
|
||||||
/// 결재를 생성한다.
|
/// 결재를 생성한다.
|
||||||
Future<Approval> create(ApprovalInput input);
|
Future<Approval> create(ApprovalCreateInput input);
|
||||||
|
|
||||||
/// 결재를 수정한다.
|
/// 결재를 수정한다.
|
||||||
Future<Approval> update(int id, ApprovalInput input);
|
Future<Approval> update(ApprovalUpdateInput input);
|
||||||
|
|
||||||
/// 결재를 삭제한다.
|
/// 결재를 삭제한다.
|
||||||
Future<void> delete(int id);
|
Future<void> delete(int id);
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class ApprovalController extends ChangeNotifier {
|
|||||||
bool _isLoadingList = false;
|
bool _isLoadingList = false;
|
||||||
bool _isLoadingDetail = false;
|
bool _isLoadingDetail = false;
|
||||||
bool _isLoadingActions = false;
|
bool _isLoadingActions = false;
|
||||||
|
bool _isSubmitting = false;
|
||||||
bool _isPerformingAction = false;
|
bool _isPerformingAction = false;
|
||||||
int? _processingStepId;
|
int? _processingStepId;
|
||||||
bool _isLoadingTemplates = false;
|
bool _isLoadingTemplates = false;
|
||||||
@@ -72,6 +73,7 @@ class ApprovalController extends ChangeNotifier {
|
|||||||
List<ApprovalAction> _actions = const [];
|
List<ApprovalAction> _actions = const [];
|
||||||
List<ApprovalTemplate> _templates = const [];
|
List<ApprovalTemplate> _templates = const [];
|
||||||
final Map<String, LookupItem> _statusLookup = {};
|
final Map<String, LookupItem> _statusLookup = {};
|
||||||
|
List<LookupItem> _statusOptions = const [];
|
||||||
final Map<String, String> _statusCodeAliases = Map.fromEntries(
|
final Map<String, String> _statusCodeAliases = Map.fromEntries(
|
||||||
_defaultStatusCodes.entries.map(
|
_defaultStatusCodes.entries.map(
|
||||||
(entry) => MapEntry(entry.value, entry.value),
|
(entry) => MapEntry(entry.value, entry.value),
|
||||||
@@ -83,6 +85,7 @@ class ApprovalController extends ChangeNotifier {
|
|||||||
bool get isLoadingList => _isLoadingList;
|
bool get isLoadingList => _isLoadingList;
|
||||||
bool get isLoadingDetail => _isLoadingDetail;
|
bool get isLoadingDetail => _isLoadingDetail;
|
||||||
bool get isLoadingActions => _isLoadingActions;
|
bool get isLoadingActions => _isLoadingActions;
|
||||||
|
bool get isSubmitting => _isSubmitting;
|
||||||
bool get isPerformingAction => _isPerformingAction;
|
bool get isPerformingAction => _isPerformingAction;
|
||||||
int? get processingStepId => _processingStepId;
|
int? get processingStepId => _processingStepId;
|
||||||
String? get errorMessage => _errorMessage;
|
String? get errorMessage => _errorMessage;
|
||||||
@@ -107,6 +110,35 @@ class ApprovalController extends ChangeNotifier {
|
|||||||
return reason;
|
return reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<LookupItem> get approvalStatusOptions => _statusOptions;
|
||||||
|
|
||||||
|
int? get defaultApprovalStatusId {
|
||||||
|
if (_statusOptions.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final defaultItem = _statusOptions.firstWhere(
|
||||||
|
(item) => item.isDefault,
|
||||||
|
orElse: () => _statusOptions.first,
|
||||||
|
);
|
||||||
|
return defaultItem.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
LookupItem? approvalStatusById(int? id) {
|
||||||
|
if (id == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final lookup = _statusLookup[id.toString()];
|
||||||
|
if (lookup != null) {
|
||||||
|
return lookup;
|
||||||
|
}
|
||||||
|
for (final item in _statusOptions) {
|
||||||
|
if (item.id == id) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, LookupItem> get statusLookup => _statusLookup;
|
Map<String, LookupItem> get statusLookup => _statusLookup;
|
||||||
|
|
||||||
/// 필터 조건과 페이지 정보를 기반으로 결재 목록을 조회한다.
|
/// 필터 조건과 페이지 정보를 기반으로 결재 목록을 조회한다.
|
||||||
@@ -174,6 +206,7 @@ class ApprovalController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final items = await repository.fetchApprovalStatuses();
|
final items = await repository.fetchApprovalStatuses();
|
||||||
|
_statusOptions = List.unmodifiable(items);
|
||||||
_statusLookup
|
_statusLookup
|
||||||
..clear()
|
..clear()
|
||||||
..addEntries(
|
..addEntries(
|
||||||
@@ -311,6 +344,53 @@ class ApprovalController extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 결재를 생성하고 목록/상세 상태를 최신화한다.
|
||||||
|
Future<Approval?> createApproval(ApprovalCreateInput input) async {
|
||||||
|
_setSubmitting(true);
|
||||||
|
_errorMessage = null;
|
||||||
|
try {
|
||||||
|
final created = await _repository.create(input);
|
||||||
|
await fetch(page: 1);
|
||||||
|
_selected = created;
|
||||||
|
if (created.id != null) {
|
||||||
|
await _loadProceedStatus(created.id!);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
return created;
|
||||||
|
} catch (error) {
|
||||||
|
final failure = Failure.from(error);
|
||||||
|
_errorMessage = failure.describe();
|
||||||
|
notifyListeners();
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
_setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 결재 기본 정보를 수정하고 현재 페이지를 유지한다.
|
||||||
|
Future<Approval?> updateApproval(ApprovalUpdateInput input) async {
|
||||||
|
_setSubmitting(true);
|
||||||
|
_errorMessage = null;
|
||||||
|
try {
|
||||||
|
final updated = await _repository.update(input);
|
||||||
|
final currentPage = _result?.page ?? 1;
|
||||||
|
await fetch(page: currentPage);
|
||||||
|
_selected = updated;
|
||||||
|
if (updated.id != null) {
|
||||||
|
await _loadProceedStatus(updated.id!);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
return updated;
|
||||||
|
} catch (error) {
|
||||||
|
final failure = Failure.from(error);
|
||||||
|
_errorMessage = failure.describe();
|
||||||
|
notifyListeners();
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
_setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 결재 단계에 대해 승인/반려/코멘트 등 지정된 행위를 수행한다.
|
/// 결재 단계에 대해 승인/반려/코멘트 등 지정된 행위를 수행한다.
|
||||||
///
|
///
|
||||||
/// - 유효한 단계 ID와 액션이 존재해야 하며, 실행 중에는 중복 호출을 방지한다.
|
/// - 유효한 단계 ID와 액션이 존재해야 하며, 실행 중에는 중복 호출을 방지한다.
|
||||||
@@ -477,6 +557,14 @@ class ApprovalController extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _setSubmitting(bool value) {
|
||||||
|
if (_isSubmitting == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_isSubmitting = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// 액션 타입과 동일한 코드(또는 별칭)를 가진 결재 행위를 찾는다.
|
/// 액션 타입과 동일한 코드(또는 별칭)를 가진 결재 행위를 찾는다.
|
||||||
ApprovalAction? _findActionByType(ApprovalStepActionType type) {
|
ApprovalAction? _findActionByType(ApprovalStepActionType type) {
|
||||||
final aliases = _actionAliases[type] ?? [type.code];
|
final aliases = _actionAliases[type] ?? [type.code];
|
||||||
|
|||||||
@@ -360,111 +360,318 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
|||||||
|
|
||||||
/// 신규 결재 등록 다이얼로그를 열어 UI 단계에서 필요한 필드와 안내를 제공한다.
|
/// 신규 결재 등록 다이얼로그를 열어 UI 단계에서 필요한 필드와 안내를 제공한다.
|
||||||
Future<void> _openCreateApprovalDialog() async {
|
Future<void> _openCreateApprovalDialog() async {
|
||||||
|
final approvalNoController = TextEditingController();
|
||||||
final transactionController = TextEditingController();
|
final transactionController = TextEditingController();
|
||||||
|
final requesterController = TextEditingController();
|
||||||
final noteController = TextEditingController();
|
final noteController = TextEditingController();
|
||||||
var submitted = false;
|
InventoryEmployeeSuggestion? requesterSelection;
|
||||||
|
int? statusId = _controller.defaultApprovalStatusId;
|
||||||
|
String? transactionError;
|
||||||
|
String? approvalNoError;
|
||||||
|
String? statusError;
|
||||||
|
String? requesterError;
|
||||||
|
|
||||||
final shouldShowToast = await showDialog<bool>(
|
final created = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (_) {
|
||||||
return StatefulBuilder(
|
return StatefulBuilder(
|
||||||
builder: (context, setState) {
|
builder: (context, setState) {
|
||||||
final shadTheme = ShadTheme.of(context);
|
return AnimatedBuilder(
|
||||||
final errorVisible =
|
animation: _controller,
|
||||||
submitted && transactionController.text.trim().isEmpty;
|
builder: (context, _) {
|
||||||
|
final shadTheme = ShadTheme.of(context);
|
||||||
|
final materialTheme = Theme.of(context);
|
||||||
|
final statusOptions = _controller.approvalStatusOptions;
|
||||||
|
final isSubmitting = _controller.isSubmitting;
|
||||||
|
statusId ??= _controller.defaultApprovalStatusId;
|
||||||
|
|
||||||
return SuperportDialog(
|
return SuperportDialog(
|
||||||
title: '신규 결재 등록',
|
title: '신규 결재 등록',
|
||||||
description: '트랜잭션 정보를 입력하면 API 연동 시 자동 제출이 지원됩니다.',
|
description: '트랜잭션과 결재 정보를 입력하면 즉시 생성됩니다.',
|
||||||
constraints: const BoxConstraints(maxWidth: 480),
|
constraints: const BoxConstraints(maxWidth: 540),
|
||||||
actions: [
|
actions: [
|
||||||
ShadButton.ghost(
|
ShadButton.ghost(
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
onPressed: isSubmitting
|
||||||
child: const Text('닫기'),
|
? null
|
||||||
),
|
: () => Navigator.of(context).pop(false),
|
||||||
ShadButton(
|
child: const Text('취소'),
|
||||||
key: const ValueKey('approval_create_submit'),
|
),
|
||||||
onPressed: () {
|
ShadButton(
|
||||||
final trimmed = transactionController.text.trim();
|
key: const ValueKey('approval_create_submit'),
|
||||||
setState(() => submitted = true);
|
onPressed: isSubmitting
|
||||||
if (trimmed.isEmpty) {
|
? null
|
||||||
return;
|
: () async {
|
||||||
}
|
final approvalNo = approvalNoController.text
|
||||||
Navigator.of(dialogContext).pop(true);
|
.trim();
|
||||||
},
|
final transactionText = transactionController.text
|
||||||
child: const Text('임시 저장'),
|
.trim();
|
||||||
),
|
final transactionId = int.tryParse(
|
||||||
],
|
transactionText,
|
||||||
child: Column(
|
);
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
final note = noteController.text.trim();
|
||||||
mainAxisSize: MainAxisSize.min,
|
final hasStatuses = statusOptions.isNotEmpty;
|
||||||
children: [
|
|
||||||
Text('트랜잭션 ID', style: shadTheme.textTheme.small),
|
setState(() {
|
||||||
const SizedBox(height: 8),
|
approvalNoError = approvalNo.isEmpty
|
||||||
ShadInput(
|
? '결재번호를 입력하세요.'
|
||||||
key: const ValueKey('approval_create_transaction'),
|
: null;
|
||||||
controller: transactionController,
|
transactionError = transactionText.isEmpty
|
||||||
placeholder: const Text('예: 2404-TRX-001'),
|
? '트랜잭션 ID를 입력하세요.'
|
||||||
onChanged: (_) => setState(() {}),
|
: (transactionId == null
|
||||||
),
|
? '트랜잭션 ID는 숫자만 입력하세요.'
|
||||||
if (errorVisible)
|
: null);
|
||||||
Padding(
|
statusError = (!hasStatuses || statusId == null)
|
||||||
padding: const EdgeInsets.only(top: 6),
|
? '결재 상태를 선택하세요.'
|
||||||
child: Text(
|
: null;
|
||||||
'트랜잭션 ID를 입력해야 결재 생성이 가능합니다.',
|
requesterError = requesterSelection == null
|
||||||
style: shadTheme.textTheme.small.copyWith(
|
? '상신자를 선택하세요.'
|
||||||
color: Theme.of(context).colorScheme.error,
|
: null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (approvalNoError != null ||
|
||||||
|
transactionError != null ||
|
||||||
|
statusError != null ||
|
||||||
|
requesterError != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final input = ApprovalCreateInput(
|
||||||
|
transactionId: transactionId!,
|
||||||
|
approvalNo: approvalNo,
|
||||||
|
approvalStatusId: statusId!,
|
||||||
|
requestedById: requesterSelection!.id,
|
||||||
|
note: note.isEmpty ? null : note,
|
||||||
|
);
|
||||||
|
final result = await _controller.createApproval(
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
if (!mounted || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result != null) {
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: isSubmitting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text('등록'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('결재번호', style: shadTheme.textTheme.small),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ShadInput(
|
||||||
|
controller: approvalNoController,
|
||||||
|
enabled: !isSubmitting,
|
||||||
|
placeholder: const Text('예: APP-2025-0001'),
|
||||||
|
onChanged: (_) {
|
||||||
|
if (approvalNoError != null) {
|
||||||
|
setState(() => approvalNoError = null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (approvalNoError != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Text(
|
||||||
|
approvalNoError!,
|
||||||
|
style: shadTheme.textTheme.small.copyWith(
|
||||||
|
color: materialTheme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('트랜잭션 ID', style: shadTheme.textTheme.small),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ShadInput(
|
||||||
|
key: const ValueKey('approval_create_transaction'),
|
||||||
|
controller: transactionController,
|
||||||
|
enabled: !isSubmitting,
|
||||||
|
placeholder: const Text('예: 9001'),
|
||||||
|
onChanged: (_) {
|
||||||
|
if (transactionError != null) {
|
||||||
|
setState(() => transactionError = null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (transactionError != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Text(
|
||||||
|
transactionError!,
|
||||||
|
style: shadTheme.textTheme.small.copyWith(
|
||||||
|
color: materialTheme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('결재 상태', style: shadTheme.textTheme.small),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (statusOptions.isEmpty)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: shadTheme.colorScheme.border,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'결재 상태 정보를 불러오지 못했습니다. 다시 시도해주세요.',
|
||||||
|
style: shadTheme.textTheme.muted,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ShadSelect<int>(
|
||||||
|
key: ValueKey(statusOptions.length),
|
||||||
|
initialValue: statusId,
|
||||||
|
enabled: !isSubmitting,
|
||||||
|
placeholder: const Text('결재 상태 선택'),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
statusId = value;
|
||||||
|
statusError = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
selectedOptionBuilder: (context, value) {
|
||||||
|
final selected = _controller.approvalStatusById(
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
return Text(selected?.name ?? '결재 상태 선택');
|
||||||
|
},
|
||||||
|
options: statusOptions
|
||||||
|
.map(
|
||||||
|
(item) => ShadOption<int>(
|
||||||
|
value: item.id,
|
||||||
|
child: Text(item.name),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
if (statusError != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Text(
|
||||||
|
statusError!,
|
||||||
|
style: shadTheme.textTheme.small.copyWith(
|
||||||
|
color: materialTheme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('상신자', style: shadTheme.textTheme.small),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: InventoryEmployeeAutocompleteField(
|
||||||
|
controller: requesterController,
|
||||||
|
initialSuggestion: requesterSelection,
|
||||||
|
onSuggestionSelected: (suggestion) {
|
||||||
|
setState(() {
|
||||||
|
requesterSelection = suggestion;
|
||||||
|
requesterError = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onChanged: () {
|
||||||
|
if (requesterController.text.trim().isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
requesterSelection = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: !isSubmitting,
|
||||||
|
placeholder: '상신자 이름 또는 사번 검색',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ShadButton.ghost(
|
||||||
|
onPressed:
|
||||||
|
requesterSelection == null &&
|
||||||
|
requesterController.text.isEmpty
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
setState(() {
|
||||||
|
requesterSelection = null;
|
||||||
|
requesterController.clear();
|
||||||
|
requesterError = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: const Text('초기화'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (requesterError != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Text(
|
||||||
|
requesterError!,
|
||||||
|
style: shadTheme.textTheme.small.copyWith(
|
||||||
|
color: materialTheme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('비고 (선택)', style: shadTheme.textTheme.small),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ShadTextarea(
|
||||||
|
key: const ValueKey('approval_create_note'),
|
||||||
|
controller: noteController,
|
||||||
|
enabled: !isSubmitting,
|
||||||
|
minHeight: 120,
|
||||||
|
maxHeight: 220,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: shadTheme.colorScheme.mutedForeground
|
||||||
|
.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: const [
|
||||||
|
Text(
|
||||||
|
'저장 안내',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'저장 시 결재가 생성되고 첫 단계와 현재 상태가 API 규격에 맞춰 초기화됩니다. 등록 후 목록이 자동으로 갱신됩니다.',
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text('비고 (선택)', style: shadTheme.textTheme.small),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
ShadTextarea(
|
|
||||||
key: const ValueKey('approval_create_note'),
|
|
||||||
controller: noteController,
|
|
||||||
minHeight: 120,
|
|
||||||
maxHeight: 220,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
);
|
||||||
Container(
|
},
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: shadTheme.colorScheme.mutedForeground.withValues(
|
|
||||||
alpha: 0.08,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: const [
|
|
||||||
Text(
|
|
||||||
'API 연동 준비 중',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
SizedBox(height: 6),
|
|
||||||
Text(
|
|
||||||
'현재는 결재 생성 UI만 제공됩니다. 실제 저장은 백엔드 연동 이후 지원될 예정입니다.',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
approvalNoController.dispose();
|
||||||
transactionController.dispose();
|
transactionController.dispose();
|
||||||
|
requesterController.dispose();
|
||||||
noteController.dispose();
|
noteController.dispose();
|
||||||
|
|
||||||
if (shouldShowToast == true && mounted) {
|
if (created == true && mounted) {
|
||||||
SuperportToast.info(
|
SuperportToast.success(context, '결재를 생성했습니다.');
|
||||||
context,
|
|
||||||
'결재 생성은 API 연동 이후 지원될 예정입니다. 입력한 값은 실제로 저장되지 않았습니다.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
82
lib/features/auth/application/auth_service.dart
Normal file
82
lib/features/auth/application/auth_service.dart
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../../../core/network/interceptors/auth_interceptor.dart';
|
||||||
|
import '../../../core/services/token_storage.dart';
|
||||||
|
import '../domain/entities/auth_session.dart';
|
||||||
|
import '../domain/entities/login_request.dart';
|
||||||
|
import '../domain/repositories/auth_repository.dart';
|
||||||
|
|
||||||
|
/// 인증 세션을 관리하고 토큰을 보관하는 서비스.
|
||||||
|
class AuthService extends ChangeNotifier {
|
||||||
|
AuthService({
|
||||||
|
required AuthRepository repository,
|
||||||
|
required TokenStorage tokenStorage,
|
||||||
|
}) : _repository = repository,
|
||||||
|
_tokenStorage = tokenStorage;
|
||||||
|
|
||||||
|
final AuthRepository _repository;
|
||||||
|
final TokenStorage _tokenStorage;
|
||||||
|
|
||||||
|
AuthSession? _session;
|
||||||
|
bool _rememberMe = false;
|
||||||
|
|
||||||
|
/// 현재 로그인된 세션 (없으면 null)
|
||||||
|
AuthSession? get session => _session;
|
||||||
|
|
||||||
|
/// 사용자가 마지막으로 선택한 자동 로그인 여부
|
||||||
|
bool get rememberMe => _rememberMe;
|
||||||
|
|
||||||
|
/// 로그인 후 세션을 저장하고 토큰을 보관한다.
|
||||||
|
Future<AuthSession> login(LoginRequest request) async {
|
||||||
|
final session = await _repository.login(request);
|
||||||
|
_rememberMe = request.rememberMe;
|
||||||
|
await _persistSession(session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 저장된 리프레시 토큰으로 세션을 갱신한다.
|
||||||
|
Future<AuthSession?> refreshSession() async {
|
||||||
|
final refreshToken = await _tokenStorage.readRefreshToken();
|
||||||
|
if (refreshToken == null || refreshToken.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final session = await _repository.refresh(refreshToken);
|
||||||
|
await _persistSession(session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 앱 내에서 명시적으로 로그아웃할 때 호출한다.
|
||||||
|
Future<void> clearSession() async {
|
||||||
|
_session = null;
|
||||||
|
_rememberMe = false;
|
||||||
|
await _tokenStorage.clear();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 인터셉터에서 사용할 토큰 쌍을 반환한다.
|
||||||
|
Future<TokenPair?> refreshForInterceptor() async {
|
||||||
|
try {
|
||||||
|
final session = await refreshSession();
|
||||||
|
if (session == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return TokenPair(
|
||||||
|
accessToken: session.accessToken,
|
||||||
|
refreshToken: session.refreshToken,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistSession(AuthSession session) async {
|
||||||
|
_session = session;
|
||||||
|
await _tokenStorage.writeAccessToken(session.accessToken);
|
||||||
|
if (session.hasRefreshToken) {
|
||||||
|
await _tokenStorage.writeRefreshToken(session.refreshToken);
|
||||||
|
} else {
|
||||||
|
await _tokenStorage.writeRefreshToken(null);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
162
lib/features/auth/data/dtos/auth_session_dto.dart
Normal file
162
lib/features/auth/data/dtos/auth_session_dto.dart
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import '../../../../core/common/utils/json_utils.dart';
|
||||||
|
import '../../domain/entities/auth_permission.dart';
|
||||||
|
import '../../domain/entities/auth_session.dart';
|
||||||
|
import '../../domain/entities/authenticated_user.dart';
|
||||||
|
|
||||||
|
/// 로그인/토큰 갱신 응답을 역직렬화하는 DTO.
|
||||||
|
class AuthSessionDto {
|
||||||
|
AuthSessionDto({
|
||||||
|
required this.accessToken,
|
||||||
|
required this.refreshToken,
|
||||||
|
required this.user,
|
||||||
|
this.expiresAt,
|
||||||
|
this.permissions = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
final String accessToken;
|
||||||
|
final String refreshToken;
|
||||||
|
final DateTime? expiresAt;
|
||||||
|
final AuthenticatedUser user;
|
||||||
|
final List<AuthPermissionDto> permissions;
|
||||||
|
|
||||||
|
factory AuthSessionDto.fromJson(Map<String, dynamic> json) {
|
||||||
|
final token = _readString(json, 'access_token');
|
||||||
|
final refresh = _readString(json, 'refresh_token');
|
||||||
|
final expires = _parseDate(_readString(json, 'expires_at'));
|
||||||
|
final userMap = _readMap(json, 'user');
|
||||||
|
final permissionList = _readList(json, 'permissions');
|
||||||
|
return AuthSessionDto(
|
||||||
|
accessToken: token ?? '',
|
||||||
|
refreshToken: refresh ?? '',
|
||||||
|
expiresAt: expires,
|
||||||
|
user: _parseUser(userMap),
|
||||||
|
permissions: permissionList
|
||||||
|
.map(AuthPermissionDto.fromJson)
|
||||||
|
.toList(growable: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthSession toEntity() {
|
||||||
|
return AuthSession(
|
||||||
|
accessToken: accessToken,
|
||||||
|
refreshToken: refreshToken,
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
user: user,
|
||||||
|
permissions: permissions
|
||||||
|
.map((dto) => dto.toEntity())
|
||||||
|
.toList(growable: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthPermissionDto {
|
||||||
|
const AuthPermissionDto({required this.resource, required this.actions});
|
||||||
|
|
||||||
|
final String resource;
|
||||||
|
final List<String> actions;
|
||||||
|
|
||||||
|
factory AuthPermissionDto.fromJson(Map<String, dynamic>? json) {
|
||||||
|
if (json == null) {
|
||||||
|
throw const FormatException('권한 정보가 비어 있습니다.');
|
||||||
|
}
|
||||||
|
final resource = _readString(json, 'resource') ?? '';
|
||||||
|
final actions = <String>[];
|
||||||
|
final rawActions = json['actions'];
|
||||||
|
if (rawActions is List) {
|
||||||
|
for (final item in rawActions) {
|
||||||
|
if (item is String) {
|
||||||
|
final normalized = item.trim().toLowerCase();
|
||||||
|
if (normalized.isNotEmpty) {
|
||||||
|
actions.add(normalized);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item is Map<String, dynamic>) {
|
||||||
|
for (final entry in item.entries) {
|
||||||
|
final normalized = entry.value.toString().trim().toLowerCase();
|
||||||
|
if (normalized.isNotEmpty) {
|
||||||
|
actions.add(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return AuthPermissionDto(
|
||||||
|
resource: resource,
|
||||||
|
actions: actions.toList(growable: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthPermission toEntity() =>
|
||||||
|
AuthPermission(resource: resource, actions: actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthenticatedUser _parseUser(Map<String, dynamic> json) {
|
||||||
|
final id = _readOptionalInt(json, 'id') ?? 0;
|
||||||
|
final name = _readString(json, 'name') ?? '';
|
||||||
|
final employeeNo = _readString(json, 'employee_no');
|
||||||
|
final email = _readString(json, 'email');
|
||||||
|
final group = JsonUtils.extractMap(
|
||||||
|
json,
|
||||||
|
keys: const ['group', 'primary_group'],
|
||||||
|
);
|
||||||
|
return AuthenticatedUser(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
employeeNo: employeeNo,
|
||||||
|
email: email,
|
||||||
|
primaryGroupId: _readOptionalInt(group, 'id'),
|
||||||
|
primaryGroupName: _readString(group, 'name'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _readString(Map<String, dynamic>? source, String key) {
|
||||||
|
if (source == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final value = source[key];
|
||||||
|
if (value is String) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> _readList(Map<String, dynamic> source, String key) {
|
||||||
|
final value = source[key];
|
||||||
|
if (value is List) {
|
||||||
|
return value.whereType<Map<String, dynamic>>().toList(growable: false);
|
||||||
|
}
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _readMap(Map<String, dynamic> source, String key) {
|
||||||
|
final value = source[key];
|
||||||
|
if (value is Map<String, dynamic>) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _parseDate(String? value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return DateTime.tryParse(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _readOptionalInt(Map<String, dynamic>? source, String key) {
|
||||||
|
if (source == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final value = source[key];
|
||||||
|
if (value is int) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value is String) {
|
||||||
|
return int.tryParse(value);
|
||||||
|
}
|
||||||
|
if (value is double) {
|
||||||
|
return value.round();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../../../core/network/api_client.dart';
|
||||||
|
import '../../../../core/network/api_routes.dart';
|
||||||
|
import '../../domain/entities/auth_session.dart';
|
||||||
|
import '../../domain/entities/login_request.dart';
|
||||||
|
import '../../domain/repositories/auth_repository.dart';
|
||||||
|
import '../dtos/auth_session_dto.dart';
|
||||||
|
|
||||||
|
/// 인증 관련 엔드포인트를 호출하는 원격 저장소 구현체.
|
||||||
|
class AuthRepositoryRemote implements AuthRepository {
|
||||||
|
AuthRepositoryRemote({required ApiClient apiClient}) : _api = apiClient;
|
||||||
|
|
||||||
|
final ApiClient _api;
|
||||||
|
|
||||||
|
static const _basePath = '${ApiRoutes.apiV1}/auth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AuthSession> login(LoginRequest request) async {
|
||||||
|
final response = await _api.post<Map<String, dynamic>>(
|
||||||
|
'$_basePath/login',
|
||||||
|
data: {
|
||||||
|
'identifier': request.identifier,
|
||||||
|
'password': request.password,
|
||||||
|
'remember_me': request.rememberMe,
|
||||||
|
},
|
||||||
|
options: Options(responseType: ResponseType.json),
|
||||||
|
);
|
||||||
|
final json = (response.data?['data'] as Map<String, dynamic>?) ?? {};
|
||||||
|
return AuthSessionDto.fromJson(json).toEntity();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AuthSession> refresh(String refreshToken) async {
|
||||||
|
final response = await _api.post<Map<String, dynamic>>(
|
||||||
|
'$_basePath/refresh',
|
||||||
|
data: {'refresh_token': refreshToken},
|
||||||
|
options: Options(responseType: ResponseType.json),
|
||||||
|
);
|
||||||
|
final json = (response.data?['data'] as Map<String, dynamic>?) ?? {};
|
||||||
|
return AuthSessionDto.fromJson(json).toEntity();
|
||||||
|
}
|
||||||
|
}
|
||||||
32
lib/features/auth/domain/entities/auth_permission.dart
Normal file
32
lib/features/auth/domain/entities/auth_permission.dart
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import '../../../../core/permissions/permission_manager.dart';
|
||||||
|
import '../../../../core/permissions/permission_resources.dart';
|
||||||
|
|
||||||
|
/// 로그인 응답에서 내려오는 단일 권한(리소스 + 액션 목록)을 표현한다.
|
||||||
|
class AuthPermission {
|
||||||
|
const AuthPermission({required this.resource, required this.actions});
|
||||||
|
|
||||||
|
/// 서버가 반환한 리소스 식별자 (예: `/stock-transactions`)
|
||||||
|
final String resource;
|
||||||
|
|
||||||
|
/// 허용된 액션 문자열 목록 (예: `view`, `create`)
|
||||||
|
final List<String> actions;
|
||||||
|
|
||||||
|
/// [PermissionManager]가 이해할 수 있는 포맷으로 변환한다.
|
||||||
|
Map<String, Set<PermissionAction>> toPermissionMap() {
|
||||||
|
final normalized = PermissionResources.normalize(resource);
|
||||||
|
final actionSet = <PermissionAction>{};
|
||||||
|
for (final raw in actions) {
|
||||||
|
final matched = PermissionAction.values.where(
|
||||||
|
(action) => action.name == raw.trim().toLowerCase(),
|
||||||
|
);
|
||||||
|
if (matched.isEmpty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
actionSet.addAll(matched);
|
||||||
|
}
|
||||||
|
if (actionSet.isEmpty) {
|
||||||
|
return <String, Set<PermissionAction>>{};
|
||||||
|
}
|
||||||
|
return {normalized: actionSet};
|
||||||
|
}
|
||||||
|
}
|
||||||
31
lib/features/auth/domain/entities/auth_session.dart
Normal file
31
lib/features/auth/domain/entities/auth_session.dart
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import 'auth_permission.dart';
|
||||||
|
import 'authenticated_user.dart';
|
||||||
|
|
||||||
|
/// 로그인 또는 토큰 갱신 결과를 표현하는 세션 모델.
|
||||||
|
class AuthSession {
|
||||||
|
const AuthSession({
|
||||||
|
required this.accessToken,
|
||||||
|
required this.refreshToken,
|
||||||
|
required this.expiresAt,
|
||||||
|
required this.user,
|
||||||
|
this.permissions = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
/// API 인증에 사용되는 액세스 토큰.
|
||||||
|
final String accessToken;
|
||||||
|
|
||||||
|
/// 토큰 갱신에 사용되는 리프레시 토큰.
|
||||||
|
final String refreshToken;
|
||||||
|
|
||||||
|
/// 액세스 토큰 만료 시각.
|
||||||
|
final DateTime? expiresAt;
|
||||||
|
|
||||||
|
/// 로그인한 사용자 정보.
|
||||||
|
final AuthenticatedUser user;
|
||||||
|
|
||||||
|
/// 사용자에게 할당된 권한 목록.
|
||||||
|
final List<AuthPermission> permissions;
|
||||||
|
|
||||||
|
/// 리프레시 토큰이 유효한지 여부를 단순 판단한다.
|
||||||
|
bool get hasRefreshToken => refreshToken.isNotEmpty;
|
||||||
|
}
|
||||||
29
lib/features/auth/domain/entities/authenticated_user.dart
Normal file
29
lib/features/auth/domain/entities/authenticated_user.dart
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/// 로그인 성공 시 반환되는 사용자 정보.
|
||||||
|
class AuthenticatedUser {
|
||||||
|
const AuthenticatedUser({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.employeeNo,
|
||||||
|
this.email,
|
||||||
|
this.primaryGroupId,
|
||||||
|
this.primaryGroupName,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 사용자 식별자
|
||||||
|
final int id;
|
||||||
|
|
||||||
|
/// 이름
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
/// 사번
|
||||||
|
final String? employeeNo;
|
||||||
|
|
||||||
|
/// 이메일
|
||||||
|
final String? email;
|
||||||
|
|
||||||
|
/// 기본 소속 그룹 ID
|
||||||
|
final int? primaryGroupId;
|
||||||
|
|
||||||
|
/// 기본 소속 그룹명
|
||||||
|
final String? primaryGroupName;
|
||||||
|
}
|
||||||
21
lib/features/auth/domain/entities/login_request.dart
Normal file
21
lib/features/auth/domain/entities/login_request.dart
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/// 로그인 요청 값을 표현하는 도메인 모델.
|
||||||
|
///
|
||||||
|
/// - identifier: 사번 또는 이메일 등 사용자가 입력한 계정 식별자
|
||||||
|
/// - password: 평문 비밀번호
|
||||||
|
/// - rememberMe: 재로그인 시 토큰 유지를 원하는지 여부
|
||||||
|
class LoginRequest {
|
||||||
|
const LoginRequest({
|
||||||
|
required this.identifier,
|
||||||
|
required this.password,
|
||||||
|
this.rememberMe = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 사번 또는 이메일
|
||||||
|
final String identifier;
|
||||||
|
|
||||||
|
/// 평문 비밀번호
|
||||||
|
final String password;
|
||||||
|
|
||||||
|
/// 재로그인 시 토큰 지속 여부
|
||||||
|
final bool rememberMe;
|
||||||
|
}
|
||||||
11
lib/features/auth/domain/repositories/auth_repository.dart
Normal file
11
lib/features/auth/domain/repositories/auth_repository.dart
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import '../entities/auth_session.dart';
|
||||||
|
import '../entities/login_request.dart';
|
||||||
|
|
||||||
|
/// 인증 관련 API 호출을 추상화한 저장소 인터페이스.
|
||||||
|
abstract class AuthRepository {
|
||||||
|
/// 로그인 API를 호출해 세션을 생성한다.
|
||||||
|
Future<AuthSession> login(LoginRequest request);
|
||||||
|
|
||||||
|
/// 리프레시 토큰으로 새로운 세션을 발급받는다.
|
||||||
|
Future<AuthSession> refresh(String refreshToken);
|
||||||
|
}
|
||||||
204
lib/features/dashboard/data/dtos/dashboard_summary_dto.dart
Normal file
204
lib/features/dashboard/data/dtos/dashboard_summary_dto.dart
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import '../../../../core/common/utils/json_utils.dart';
|
||||||
|
import '../../domain/entities/dashboard_kpi.dart';
|
||||||
|
import '../../domain/entities/dashboard_pending_approval.dart';
|
||||||
|
import '../../domain/entities/dashboard_summary.dart';
|
||||||
|
import '../../domain/entities/dashboard_transaction_summary.dart';
|
||||||
|
|
||||||
|
/// 대시보드 요약 응답 DTO.
|
||||||
|
class DashboardSummaryDto {
|
||||||
|
const DashboardSummaryDto({
|
||||||
|
this.generatedAt,
|
||||||
|
this.kpis = const [],
|
||||||
|
this.recentTransactions = const [],
|
||||||
|
this.pendingApprovals = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
final DateTime? generatedAt;
|
||||||
|
final List<DashboardKpiDto> kpis;
|
||||||
|
final List<DashboardTransactionDto> recentTransactions;
|
||||||
|
final List<DashboardApprovalDto> pendingApprovals;
|
||||||
|
|
||||||
|
factory DashboardSummaryDto.fromJson(Map<String, dynamic> json) {
|
||||||
|
final generatedAt = _parseDate(json['generated_at']);
|
||||||
|
final kpiList = JsonUtils.extractList(json, keys: const ['kpis'])
|
||||||
|
.map(DashboardKpiDto.fromJson)
|
||||||
|
.toList(growable: false);
|
||||||
|
final transactionList =
|
||||||
|
JsonUtils.extractList(json, keys: const ['recent_transactions'])
|
||||||
|
.map(DashboardTransactionDto.fromJson)
|
||||||
|
.toList(growable: false);
|
||||||
|
final approvalList =
|
||||||
|
JsonUtils.extractList(json, keys: const ['pending_approvals'])
|
||||||
|
.map(DashboardApprovalDto.fromJson)
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
return DashboardSummaryDto(
|
||||||
|
generatedAt: generatedAt,
|
||||||
|
kpis: kpiList,
|
||||||
|
recentTransactions: transactionList,
|
||||||
|
pendingApprovals: approvalList,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DashboardSummary toEntity() {
|
||||||
|
return DashboardSummary(
|
||||||
|
generatedAt: generatedAt,
|
||||||
|
kpis: kpis.map((dto) => dto.toEntity()).toList(growable: false),
|
||||||
|
recentTransactions: recentTransactions
|
||||||
|
.map((dto) => dto.toEntity())
|
||||||
|
.toList(growable: false),
|
||||||
|
pendingApprovals: pendingApprovals
|
||||||
|
.map((dto) => dto.toEntity())
|
||||||
|
.toList(growable: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DashboardKpiDto {
|
||||||
|
const DashboardKpiDto({
|
||||||
|
required this.key,
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
this.trendLabel,
|
||||||
|
this.delta,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String key;
|
||||||
|
final String label;
|
||||||
|
final num value;
|
||||||
|
final String? trendLabel;
|
||||||
|
final double? delta;
|
||||||
|
|
||||||
|
factory DashboardKpiDto.fromJson(Map<String, dynamic> json) {
|
||||||
|
final key = _readString(json, 'key') ?? '';
|
||||||
|
final label = _readString(json, 'label') ?? key;
|
||||||
|
final value = _readNum(json, 'value');
|
||||||
|
final trendLabel = _readString(json, 'trend_label');
|
||||||
|
final delta = _readDouble(json, 'delta');
|
||||||
|
return DashboardKpiDto(
|
||||||
|
key: key,
|
||||||
|
label: label,
|
||||||
|
value: value ?? 0,
|
||||||
|
trendLabel: trendLabel,
|
||||||
|
delta: delta,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DashboardKpi toEntity() {
|
||||||
|
return DashboardKpi(
|
||||||
|
key: key,
|
||||||
|
label: label,
|
||||||
|
value: value,
|
||||||
|
trendLabel: trendLabel,
|
||||||
|
delta: delta,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DashboardTransactionDto {
|
||||||
|
const DashboardTransactionDto({
|
||||||
|
required this.transactionNo,
|
||||||
|
required this.transactionDate,
|
||||||
|
required this.transactionType,
|
||||||
|
required this.statusName,
|
||||||
|
required this.createdBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String transactionNo;
|
||||||
|
final String transactionDate;
|
||||||
|
final String transactionType;
|
||||||
|
final String statusName;
|
||||||
|
final String createdBy;
|
||||||
|
|
||||||
|
factory DashboardTransactionDto.fromJson(Map<String, dynamic> json) {
|
||||||
|
return DashboardTransactionDto(
|
||||||
|
transactionNo: _readString(json, 'transaction_no') ?? '',
|
||||||
|
transactionDate: _readString(json, 'transaction_date') ?? '',
|
||||||
|
transactionType: _readString(json, 'transaction_type') ?? '',
|
||||||
|
statusName: _readString(json, 'status_name') ?? '',
|
||||||
|
createdBy: _readString(json, 'created_by') ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DashboardTransactionSummary toEntity() {
|
||||||
|
return DashboardTransactionSummary(
|
||||||
|
transactionNo: transactionNo,
|
||||||
|
transactionDate: transactionDate,
|
||||||
|
transactionType: transactionType,
|
||||||
|
statusName: statusName,
|
||||||
|
createdBy: createdBy,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DashboardApprovalDto {
|
||||||
|
const DashboardApprovalDto({
|
||||||
|
required this.approvalNo,
|
||||||
|
required this.title,
|
||||||
|
required this.stepSummary,
|
||||||
|
this.requestedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String approvalNo;
|
||||||
|
final String title;
|
||||||
|
final String stepSummary;
|
||||||
|
final String? requestedAt;
|
||||||
|
|
||||||
|
factory DashboardApprovalDto.fromJson(Map<String, dynamic> json) {
|
||||||
|
return DashboardApprovalDto(
|
||||||
|
approvalNo: _readString(json, 'approval_no') ?? '',
|
||||||
|
title: _readString(json, 'title') ?? '',
|
||||||
|
stepSummary: _readString(json, 'step_summary') ?? '',
|
||||||
|
requestedAt: _readString(json, 'requested_at'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DashboardPendingApproval toEntity() {
|
||||||
|
return DashboardPendingApproval(
|
||||||
|
approvalNo: approvalNo,
|
||||||
|
title: title,
|
||||||
|
stepSummary: stepSummary,
|
||||||
|
requestedAt: requestedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _parseDate(Object? value) {
|
||||||
|
if (value is DateTime) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value is String) {
|
||||||
|
return DateTime.tryParse(value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _readString(Map<String, dynamic>? source, String key) {
|
||||||
|
if (source == null) return null;
|
||||||
|
final value = source[key];
|
||||||
|
if (value is String) {
|
||||||
|
final trimmed = value.trim();
|
||||||
|
return trimmed.isEmpty ? null : trimmed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
num? _readNum(Map<String, dynamic>? source, String key) {
|
||||||
|
if (source == null) return null;
|
||||||
|
final value = source[key];
|
||||||
|
if (value is num) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value is String) {
|
||||||
|
return num.tryParse(value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
double? _readDouble(Map<String, dynamic>? source, String key) {
|
||||||
|
final value = _readNum(source, key);
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value.toDouble();
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../../../core/network/api_client.dart';
|
||||||
|
import '../../../../core/network/api_routes.dart';
|
||||||
|
import '../../domain/entities/dashboard_summary.dart';
|
||||||
|
import '../../domain/repositories/dashboard_repository.dart';
|
||||||
|
import '../dtos/dashboard_summary_dto.dart';
|
||||||
|
|
||||||
|
/// 대시보드 요약 데이터를 불러오는 원격 저장소.
|
||||||
|
class DashboardRepositoryRemote implements DashboardRepository {
|
||||||
|
DashboardRepositoryRemote({required ApiClient apiClient}) : _api = apiClient;
|
||||||
|
|
||||||
|
final ApiClient _api;
|
||||||
|
|
||||||
|
static const _summaryPath = '${ApiRoutes.apiV1}/dashboard/summary';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DashboardSummary> fetchSummary() async {
|
||||||
|
final response = await _api.get<Map<String, dynamic>>(
|
||||||
|
_summaryPath,
|
||||||
|
options: Options(responseType: ResponseType.json),
|
||||||
|
);
|
||||||
|
final json = (response.data?['data'] as Map<String, dynamic>?) ??
|
||||||
|
response.data ??
|
||||||
|
const <String, dynamic>{};
|
||||||
|
return DashboardSummaryDto.fromJson(json).toEntity();
|
||||||
|
}
|
||||||
|
}
|
||||||
33
lib/features/dashboard/domain/entities/dashboard_kpi.dart
Normal file
33
lib/features/dashboard/domain/entities/dashboard_kpi.dart
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/// 대시보드 KPI 카드에 사용할 수치 정보.
|
||||||
|
class DashboardKpi {
|
||||||
|
const DashboardKpi({
|
||||||
|
required this.key,
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
this.trendLabel,
|
||||||
|
this.delta,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// API에서 식별 목적으로 사용하는 키 (예: inbound, outbound)
|
||||||
|
final String key;
|
||||||
|
|
||||||
|
/// 사용자에게 노출할 라벨.
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// KPI 수치(건수 등)
|
||||||
|
final num value;
|
||||||
|
|
||||||
|
/// 전일 대비 등 비교 텍스트.
|
||||||
|
final String? trendLabel;
|
||||||
|
|
||||||
|
/// 증감 퍼센트(선택)
|
||||||
|
final double? delta;
|
||||||
|
|
||||||
|
/// 카드에 표시할 값 문자열을 생성한다.
|
||||||
|
String get displayValue {
|
||||||
|
if (value is int || value == value.roundToDouble()) {
|
||||||
|
return '${value.round()}건';
|
||||||
|
}
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/// 결재 대기 요약 정보.
|
||||||
|
class DashboardPendingApproval {
|
||||||
|
const DashboardPendingApproval({
|
||||||
|
required this.approvalNo,
|
||||||
|
required this.title,
|
||||||
|
required this.stepSummary,
|
||||||
|
this.requestedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 결재 문서 번호
|
||||||
|
final String approvalNo;
|
||||||
|
|
||||||
|
/// 결재 제목
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
/// 현재 단계/승인자 요약
|
||||||
|
final String stepSummary;
|
||||||
|
|
||||||
|
/// 상신 일시(문자열)
|
||||||
|
final String? requestedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import 'dashboard_kpi.dart';
|
||||||
|
import 'dashboard_pending_approval.dart';
|
||||||
|
import 'dashboard_transaction_summary.dart';
|
||||||
|
|
||||||
|
/// 대시보드 전체 요약 응답.
|
||||||
|
class DashboardSummary {
|
||||||
|
const DashboardSummary({
|
||||||
|
required this.generatedAt,
|
||||||
|
required this.kpis,
|
||||||
|
required this.recentTransactions,
|
||||||
|
required this.pendingApprovals,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 요약 데이터 생성 시각.
|
||||||
|
final DateTime? generatedAt;
|
||||||
|
|
||||||
|
/// KPI 카드 목록.
|
||||||
|
final List<DashboardKpi> kpis;
|
||||||
|
|
||||||
|
/// 최근 트랜잭션 목록.
|
||||||
|
final List<DashboardTransactionSummary> recentTransactions;
|
||||||
|
|
||||||
|
/// 결재 대기 목록.
|
||||||
|
final List<DashboardPendingApproval> pendingApprovals;
|
||||||
|
|
||||||
|
/// KPI를 키로 찾는다.
|
||||||
|
DashboardKpi? findKpi(String key) {
|
||||||
|
for (final kpi in kpis) {
|
||||||
|
if (kpi.key == key) {
|
||||||
|
return kpi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/// 최근 트랜잭션 요약 정보.
|
||||||
|
class DashboardTransactionSummary {
|
||||||
|
const DashboardTransactionSummary({
|
||||||
|
required this.transactionNo,
|
||||||
|
required this.transactionDate,
|
||||||
|
required this.transactionType,
|
||||||
|
required this.statusName,
|
||||||
|
required this.createdBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 트랜잭션 번호
|
||||||
|
final String transactionNo;
|
||||||
|
|
||||||
|
/// 발생 일자 (형식: yyyy-MM-dd)
|
||||||
|
final String transactionDate;
|
||||||
|
|
||||||
|
/// 입고/출고/대여 등 유형
|
||||||
|
final String transactionType;
|
||||||
|
|
||||||
|
/// 현재 상태 명칭
|
||||||
|
final String statusName;
|
||||||
|
|
||||||
|
/// 작성자 이름
|
||||||
|
final String createdBy;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import '../entities/dashboard_summary.dart';
|
||||||
|
|
||||||
|
/// 대시보드 데이터를 제공하는 저장소 인터페이스.
|
||||||
|
abstract class DashboardRepository {
|
||||||
|
/// 대시보드 요약 정보를 조회한다.
|
||||||
|
Future<DashboardSummary> fetchSummary();
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../../../../core/network/failure.dart';
|
||||||
|
import '../../domain/entities/dashboard_summary.dart';
|
||||||
|
import '../../domain/repositories/dashboard_repository.dart';
|
||||||
|
|
||||||
|
/// 대시보드 화면 상태를 관리하는 컨트롤러.
|
||||||
|
class DashboardController extends ChangeNotifier {
|
||||||
|
DashboardController({required DashboardRepository repository})
|
||||||
|
: _repository = repository;
|
||||||
|
|
||||||
|
final DashboardRepository _repository;
|
||||||
|
|
||||||
|
DashboardSummary? _summary;
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _isRefreshing = false;
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
|
DashboardSummary? get summary => _summary;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
bool get isRefreshing => _isRefreshing;
|
||||||
|
String? get errorMessage => _errorMessage;
|
||||||
|
|
||||||
|
/// 초기 로딩(캐시가 없을 때만 실행)
|
||||||
|
Future<void> ensureLoaded() async {
|
||||||
|
if (_summary != null || _isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 서버에서 최신 요약을 다시 불러온다.
|
||||||
|
Future<void> refresh() async {
|
||||||
|
_isLoading = _summary == null;
|
||||||
|
_isRefreshing = _summary != null;
|
||||||
|
_errorMessage = null;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final result = await _repository.fetchSummary();
|
||||||
|
_summary = result;
|
||||||
|
} catch (error) {
|
||||||
|
final failure = Failure.from(error);
|
||||||
|
_errorMessage = failure.describe();
|
||||||
|
} finally {
|
||||||
|
_isLoading = false;
|
||||||
|
_isRefreshing = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,115 +1,238 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.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:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
import 'package:superport_v2/widgets/app_layout.dart';
|
import 'package:superport_v2/widgets/app_layout.dart';
|
||||||
import 'package:superport_v2/widgets/components/empty_state.dart';
|
import 'package:superport_v2/widgets/components/empty_state.dart';
|
||||||
|
|
||||||
|
import '../../domain/entities/dashboard_kpi.dart';
|
||||||
|
import '../../domain/entities/dashboard_pending_approval.dart';
|
||||||
|
import '../../domain/entities/dashboard_transaction_summary.dart';
|
||||||
|
import '../../domain/repositories/dashboard_repository.dart';
|
||||||
|
import '../controllers/dashboard_controller.dart';
|
||||||
|
|
||||||
/// Superport 메인 대시보드 화면.
|
/// Superport 메인 대시보드 화면.
|
||||||
class DashboardPage extends StatelessWidget {
|
class DashboardPage extends StatefulWidget {
|
||||||
const DashboardPage({super.key});
|
const DashboardPage({super.key});
|
||||||
|
|
||||||
static const _recentTransactions = [
|
@override
|
||||||
('IN-20240312-003', '2024-03-12', '입고', '승인완료', '김담당'),
|
State<DashboardPage> createState() => _DashboardPageState();
|
||||||
('OUT-20240311-005', '2024-03-11', '출고', '출고대기', '이물류'),
|
}
|
||||||
('RENT-20240310-001', '2024-03-10', '대여', '대여중', '박대여'),
|
|
||||||
('APP-20240309-004', '2024-03-09', '결재', '진행중', '최결재'),
|
|
||||||
];
|
|
||||||
|
|
||||||
static const _pendingApprovals = [
|
class _DashboardPageState extends State<DashboardPage> {
|
||||||
('APP-20240312-010', '설비 구매', '2/4 단계 진행 중'),
|
late final DashboardController _controller;
|
||||||
('APP-20240311-004', '창고 정기 점검', '승인 대기'),
|
Timer? _autoRefreshTimer;
|
||||||
('APP-20240309-002', '계약 연장', '반려 후 재상신'),
|
final DateFormat _timestampFormat = DateFormat('yyyy-MM-dd HH:mm');
|
||||||
|
|
||||||
|
static const _kpiPresets = [
|
||||||
|
_KpiPreset(
|
||||||
|
key: 'inbound',
|
||||||
|
label: '오늘 입고',
|
||||||
|
icon: lucide.LucideIcons.packagePlus,
|
||||||
|
),
|
||||||
|
_KpiPreset(
|
||||||
|
key: 'outbound',
|
||||||
|
label: '오늘 출고',
|
||||||
|
icon: lucide.LucideIcons.packageMinus,
|
||||||
|
),
|
||||||
|
_KpiPreset(
|
||||||
|
key: 'pending_approvals',
|
||||||
|
label: '결재 대기',
|
||||||
|
icon: lucide.LucideIcons.messageSquareWarning,
|
||||||
|
),
|
||||||
|
_KpiPreset(
|
||||||
|
key: 'customer_inquiries',
|
||||||
|
label: '고객사 문의',
|
||||||
|
icon: lucide.LucideIcons.users,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = DashboardController(
|
||||||
|
repository: GetIt.I<DashboardRepository>(),
|
||||||
|
);
|
||||||
|
_controller.ensureLoaded();
|
||||||
|
_autoRefreshTimer = Timer.periodic(
|
||||||
|
const Duration(minutes: 5),
|
||||||
|
(_) => _controller.refresh(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_autoRefreshTimer?.cancel();
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AppLayout(
|
return AnimatedBuilder(
|
||||||
title: '대시보드',
|
animation: _controller,
|
||||||
subtitle: '입·출·대여 현황과 결재 대기를 한 눈에 확인합니다.',
|
builder: (context, _) {
|
||||||
breadcrumbs: const [AppBreadcrumbItem(label: '대시보드')],
|
return AppLayout(
|
||||||
child: SingleChildScrollView(
|
title: '대시보드',
|
||||||
padding: const EdgeInsets.only(right: 12, bottom: 24),
|
subtitle: '입·출·대여 현황과 결재 대기를 한 눈에 확인합니다.',
|
||||||
child: Column(
|
breadcrumbs: const [AppBreadcrumbItem(label: '대시보드')],
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
actions: [
|
||||||
children: [
|
ShadButton.ghost(
|
||||||
Wrap(
|
onPressed: _controller.isLoading ? null : _controller.refresh,
|
||||||
spacing: 16,
|
leading: _controller.isRefreshing
|
||||||
runSpacing: 16,
|
? const SizedBox(
|
||||||
children: const [
|
width: 16,
|
||||||
_KpiCard(
|
height: 16,
|
||||||
icon: lucide.LucideIcons.packagePlus,
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
label: '오늘 입고',
|
)
|
||||||
value: '12건',
|
: const Icon(lucide.LucideIcons.refreshCw, size: 16),
|
||||||
trend: '+3 vs 어제',
|
child: const Text('새로고침'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: _buildBody(context),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody(BuildContext context) {
|
||||||
|
final summary = _controller.summary;
|
||||||
|
final theme = ShadTheme.of(context);
|
||||||
|
|
||||||
|
if (_controller.isLoading && summary == null) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary == null) {
|
||||||
|
return Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 480),
|
||||||
|
child: ShadCard(
|
||||||
|
title: Text('대시보드 데이터를 불러오지 못했습니다.', style: theme.textTheme.h3),
|
||||||
|
description: Text(
|
||||||
|
_controller.errorMessage ?? '네트워크 연결을 확인한 뒤 다시 시도해 주세요.',
|
||||||
|
style: theme.textTheme.muted,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const SuperportEmptyState(
|
||||||
|
title: '데이터가 없습니다',
|
||||||
|
description: '권한 또는 네트워크 상태를 확인한 뒤 다시 시도해 주세요.',
|
||||||
),
|
),
|
||||||
_KpiCard(
|
const SizedBox(height: 16),
|
||||||
icon: lucide.LucideIcons.packageMinus,
|
Align(
|
||||||
label: '오늘 출고',
|
alignment: Alignment.centerRight,
|
||||||
value: '9건',
|
child: ShadButton(
|
||||||
trend: '-2 vs 어제',
|
onPressed: _controller.refresh,
|
||||||
),
|
child: const Text('다시 시도'),
|
||||||
_KpiCard(
|
),
|
||||||
icon: lucide.LucideIcons.messageSquareWarning,
|
|
||||||
label: '결재 대기',
|
|
||||||
value: '5건',
|
|
||||||
trend: '평균 12시간 지연',
|
|
||||||
),
|
|
||||||
_KpiCard(
|
|
||||||
icon: lucide.LucideIcons.users,
|
|
||||||
label: '고객사 문의',
|
|
||||||
value: '7건',
|
|
||||||
trend: '지원팀 확인 중',
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
),
|
||||||
LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final showSidePanel = constraints.maxWidth > 920;
|
|
||||||
return Flex(
|
|
||||||
direction: showSidePanel ? Axis.horizontal : Axis.vertical,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
flex: 3,
|
|
||||||
child: _RecentTransactionsCard(
|
|
||||||
transactions: _recentTransactions,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (showSidePanel)
|
|
||||||
const SizedBox(width: 16)
|
|
||||||
else
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Flexible(
|
|
||||||
flex: 2,
|
|
||||||
child: _PendingApprovalCard(approvals: _pendingApprovals),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
const _ReminderPanel(),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final kpiMap = {for (final item in summary.kpis) item.key: item};
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.only(right: 12, bottom: 24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (_controller.errorMessage != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
color: theme.colorScheme.destructive.withValues(alpha: 0.12),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
lucide.LucideIcons.info,
|
||||||
|
color: theme.colorScheme.destructive,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'대시보드 데이터를 최신 상태로 동기화하지 못했습니다.',
|
||||||
|
style: theme.textTheme.small,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
_controller.errorMessage!,
|
||||||
|
style: theme.textTheme.small,
|
||||||
|
),
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: _controller.refresh,
|
||||||
|
child: const Text('다시 시도'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Wrap(
|
||||||
|
spacing: 16,
|
||||||
|
runSpacing: 16,
|
||||||
|
children: [
|
||||||
|
for (final preset in _kpiPresets)
|
||||||
|
_KpiCard(
|
||||||
|
icon: preset.icon,
|
||||||
|
label: preset.label,
|
||||||
|
kpi: kpiMap[preset.key],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (summary.generatedAt != null)
|
||||||
|
Text(
|
||||||
|
'최근 갱신: ${_timestampFormat.format(summary.generatedAt!.toLocal())}',
|
||||||
|
style: theme.textTheme.small,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final showSidePanel = constraints.maxWidth > 920;
|
||||||
|
return Flex(
|
||||||
|
direction: showSidePanel ? Axis.horizontal : Axis.vertical,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: _RecentTransactionsCard(
|
||||||
|
transactions: summary.recentTransactions,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showSidePanel)
|
||||||
|
const SizedBox(width: 16)
|
||||||
|
else
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Flexible(
|
||||||
|
flex: 2,
|
||||||
|
child: _PendingApprovalCard(
|
||||||
|
approvals: summary.pendingApprovals,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const _ReminderPanel(),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _KpiCard extends StatelessWidget {
|
class _KpiCard extends StatelessWidget {
|
||||||
const _KpiCard({
|
const _KpiCard({required this.icon, required this.label, this.kpi});
|
||||||
required this.icon,
|
|
||||||
required this.label,
|
|
||||||
required this.value,
|
|
||||||
required this.trend,
|
|
||||||
});
|
|
||||||
|
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
final String value;
|
final DashboardKpi? kpi;
|
||||||
final String trend;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -124,9 +247,9 @@ class _KpiCard extends StatelessWidget {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(label, style: theme.textTheme.small),
|
Text(label, style: theme.textTheme.small),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(value, style: theme.textTheme.h3),
|
Text(kpi?.displayValue ?? '--', style: theme.textTheme.h3),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(trend, style: theme.textTheme.muted),
|
Text(kpi?.trendLabel ?? '데이터 동기화 중', style: theme.textTheme.muted),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -137,7 +260,7 @@ class _KpiCard extends StatelessWidget {
|
|||||||
class _RecentTransactionsCard extends StatelessWidget {
|
class _RecentTransactionsCard extends StatelessWidget {
|
||||||
const _RecentTransactionsCard({required this.transactions});
|
const _RecentTransactionsCard({required this.transactions});
|
||||||
|
|
||||||
final List<(String, String, String, String, String)> transactions;
|
final List<DashboardTransactionSummary> transactions;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -150,27 +273,34 @@ class _RecentTransactionsCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 320,
|
height: 320,
|
||||||
child: ShadTable.list(
|
child: transactions.isEmpty
|
||||||
header: const [
|
? const Center(
|
||||||
ShadTableCell.header(child: Text('번호')),
|
child: SuperportEmptyState(
|
||||||
ShadTableCell.header(child: Text('일자')),
|
title: '최근 트랜잭션이 없습니다',
|
||||||
ShadTableCell.header(child: Text('유형')),
|
description: '최근 7일간 생성된 입·출·대여 트랜잭션이 없습니다.',
|
||||||
ShadTableCell.header(child: Text('상태')),
|
),
|
||||||
ShadTableCell.header(child: Text('작성자')),
|
)
|
||||||
],
|
: ShadTable.list(
|
||||||
children: [
|
header: const [
|
||||||
for (final row in transactions)
|
ShadTableCell.header(child: Text('번호')),
|
||||||
[
|
ShadTableCell.header(child: Text('일자')),
|
||||||
ShadTableCell(child: Text(row.$1)),
|
ShadTableCell.header(child: Text('유형')),
|
||||||
ShadTableCell(child: Text(row.$2)),
|
ShadTableCell.header(child: Text('상태')),
|
||||||
ShadTableCell(child: Text(row.$3)),
|
ShadTableCell.header(child: Text('작성자')),
|
||||||
ShadTableCell(child: Text(row.$4)),
|
],
|
||||||
ShadTableCell(child: Text(row.$5)),
|
children: [
|
||||||
],
|
for (final row in transactions)
|
||||||
],
|
[
|
||||||
columnSpanExtent: (index) => const FixedTableSpanExtent(140),
|
ShadTableCell(child: Text(row.transactionNo)),
|
||||||
rowSpanExtent: (index) => const FixedTableSpanExtent(52),
|
ShadTableCell(child: Text(row.transactionDate)),
|
||||||
),
|
ShadTableCell(child: Text(row.transactionType)),
|
||||||
|
ShadTableCell(child: Text(row.statusName)),
|
||||||
|
ShadTableCell(child: Text(row.createdBy)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
columnSpanExtent: (index) => const FixedTableSpanExtent(140),
|
||||||
|
rowSpanExtent: (index) => const FixedTableSpanExtent(52),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -179,7 +309,7 @@ class _RecentTransactionsCard extends StatelessWidget {
|
|||||||
class _PendingApprovalCard extends StatelessWidget {
|
class _PendingApprovalCard extends StatelessWidget {
|
||||||
const _PendingApprovalCard({required this.approvals});
|
const _PendingApprovalCard({required this.approvals});
|
||||||
|
|
||||||
final List<(String, String, String)> approvals;
|
final List<DashboardPendingApproval> approvals;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -204,44 +334,53 @@ class _PendingApprovalCard extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
for (final approval in approvals)
|
for (final approval in approvals) ...[
|
||||||
Padding(
|
ListTile(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
leading: const Icon(lucide.LucideIcons.fileCheck, size: 20),
|
||||||
child: Row(
|
title: Text(approval.approvalNo, style: theme.textTheme.small),
|
||||||
|
subtitle: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Text(approval.title, style: theme.textTheme.p),
|
||||||
lucide.LucideIcons.bell,
|
const SizedBox(height: 4),
|
||||||
size: 18,
|
Text(approval.stepSummary, style: theme.textTheme.muted),
|
||||||
color: theme.colorScheme.primary,
|
if (approval.requestedAt != null &&
|
||||||
),
|
approval.requestedAt!.trim().isNotEmpty)
|
||||||
const SizedBox(width: 12),
|
Padding(
|
||||||
Expanded(
|
padding: const EdgeInsets.only(top: 4),
|
||||||
child: Column(
|
child: Text(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
'상신: ${approval.requestedAt}',
|
||||||
children: [
|
style: theme.textTheme.small,
|
||||||
Text(approval.$1, style: theme.textTheme.small),
|
),
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(approval.$2, style: theme.textTheme.h4),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(approval.$3, style: theme.textTheme.muted),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
ShadButton.ghost(
|
|
||||||
size: ShadButtonSize.sm,
|
|
||||||
child: const Text('상세'),
|
|
||||||
onPressed: () {},
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
trailing: ShadButton.ghost(
|
||||||
|
size: ShadButtonSize.sm,
|
||||||
|
child: const Text('상세'),
|
||||||
|
onPressed: () {},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
const Divider(),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _KpiPreset {
|
||||||
|
const _KpiPreset({
|
||||||
|
required this.key,
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String key;
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
}
|
||||||
|
|
||||||
class _ReminderPanel extends StatelessWidget {
|
class _ReminderPanel extends StatelessWidget {
|
||||||
const _ReminderPanel();
|
const _ReminderPanel();
|
||||||
|
|
||||||
@@ -297,7 +436,7 @@ class _ReminderItem extends StatelessWidget {
|
|||||||
return Row(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 18, color: theme.colorScheme.secondary),
|
Icon(icon, size: 18, color: theme.colorScheme.primary),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -58,9 +58,10 @@ class StockTransactionRepositoryRemote implements StockTransactionRepository {
|
|||||||
int id,
|
int id,
|
||||||
StockTransactionUpdateInput input,
|
StockTransactionUpdateInput input,
|
||||||
) async {
|
) async {
|
||||||
|
final payload = {'id': id, ...input.toPayload()};
|
||||||
final response = await _api.patch<Map<String, dynamic>>(
|
final response = await _api.patch<Map<String, dynamic>>(
|
||||||
'$_basePath/$id',
|
'$_basePath/$id',
|
||||||
data: input.toPayload(),
|
data: payload,
|
||||||
options: Options(responseType: ResponseType.json),
|
options: Options(responseType: ResponseType.json),
|
||||||
);
|
);
|
||||||
return _parseSingle(response.data);
|
return _parseSingle(response.data);
|
||||||
|
|||||||
@@ -27,17 +27,18 @@ class StockTransactionCreateInput {
|
|||||||
final StockTransactionApprovalInput? approval;
|
final StockTransactionApprovalInput? approval;
|
||||||
|
|
||||||
Map<String, dynamic> toPayload() {
|
Map<String, dynamic> toPayload() {
|
||||||
|
final sanitizedNote = note?.trim();
|
||||||
return {
|
return {
|
||||||
if (transactionNo != null && transactionNo!.trim().isNotEmpty)
|
if (transactionNo != null && transactionNo!.trim().isNotEmpty)
|
||||||
'transaction_no': transactionNo,
|
'transaction_no': transactionNo,
|
||||||
'transaction_type_id': transactionTypeId,
|
'transaction_type_id': transactionTypeId,
|
||||||
'transaction_status_id': transactionStatusId,
|
'transaction_status_id': transactionStatusId,
|
||||||
'warehouse_id': warehouseId,
|
'warehouse_id': warehouseId,
|
||||||
'transaction_date': transactionDate.toIso8601String(),
|
'transaction_date': _formatNaiveDate(transactionDate),
|
||||||
'created_by_id': createdById,
|
'created_by_id': createdById,
|
||||||
if (note != null && note!.trim().isNotEmpty) 'note': note,
|
'note': sanitizedNote,
|
||||||
if (expectedReturnDate != null)
|
if (expectedReturnDate != null)
|
||||||
'expected_return_date': expectedReturnDate!.toIso8601String(),
|
'expected_return_date': _formatNaiveDate(expectedReturnDate!),
|
||||||
if (lines.isNotEmpty)
|
if (lines.isNotEmpty)
|
||||||
'lines': lines.map((line) => line.toJson()).toList(growable: false),
|
'lines': lines.map((line) => line.toJson()).toList(growable: false),
|
||||||
if (customers.isNotEmpty)
|
if (customers.isNotEmpty)
|
||||||
@@ -62,11 +63,13 @@ class StockTransactionUpdateInput {
|
|||||||
final DateTime? expectedReturnDate;
|
final DateTime? expectedReturnDate;
|
||||||
|
|
||||||
Map<String, dynamic> toPayload() {
|
Map<String, dynamic> toPayload() {
|
||||||
|
final sanitizedNote = note?.trim();
|
||||||
return {
|
return {
|
||||||
'transaction_status_id': transactionStatusId,
|
'transaction_status_id': transactionStatusId,
|
||||||
if (note != null && note!.trim().isNotEmpty) 'note': note,
|
'note': sanitizedNote,
|
||||||
if (expectedReturnDate != null)
|
'expected_return_date': expectedReturnDate == null
|
||||||
'expected_return_date': expectedReturnDate!.toIso8601String(),
|
? null
|
||||||
|
: _formatNaiveDate(expectedReturnDate!),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,12 +91,13 @@ class TransactionLineCreateInput {
|
|||||||
final String? note;
|
final String? note;
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
|
final sanitizedNote = note?.trim();
|
||||||
return {
|
return {
|
||||||
'line_no': lineNo,
|
'line_no': lineNo,
|
||||||
'product_id': productId,
|
'product_id': productId,
|
||||||
'quantity': quantity,
|
'quantity': quantity,
|
||||||
'unit_price': unitPrice,
|
'unit_price': unitPrice,
|
||||||
if (note != null && note!.trim().isNotEmpty) 'note': note,
|
'note': sanitizedNote,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,12 +119,13 @@ class TransactionLineUpdateInput {
|
|||||||
final String? note;
|
final String? note;
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
|
final sanitizedNote = note?.trim();
|
||||||
return {
|
return {
|
||||||
'id': id,
|
'id': id,
|
||||||
if (lineNo != null) 'line_no': lineNo,
|
if (lineNo != null) 'line_no': lineNo,
|
||||||
if (quantity != null) 'quantity': quantity,
|
if (quantity != null) 'quantity': quantity,
|
||||||
if (unitPrice != null) 'unit_price': unitPrice,
|
if (unitPrice != null) 'unit_price': unitPrice,
|
||||||
if (note != null && note!.trim().isNotEmpty) 'note': note,
|
if (note != null) 'note': sanitizedNote,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,10 +138,8 @@ class TransactionCustomerCreateInput {
|
|||||||
final String? note;
|
final String? note;
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return {
|
final sanitizedNote = note?.trim();
|
||||||
'customer_id': customerId,
|
return {'customer_id': customerId, 'note': sanitizedNote};
|
||||||
if (note != null && note!.trim().isNotEmpty) 'note': note,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,10 +151,8 @@ class TransactionCustomerUpdateInput {
|
|||||||
final String? note;
|
final String? note;
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return {
|
final sanitizedNote = note?.trim();
|
||||||
'id': id,
|
return {'id': id, 'note': sanitizedNote};
|
||||||
if (note != null && note!.trim().isNotEmpty) 'note': note,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,12 +206,7 @@ class StockTransactionListFilter {
|
|||||||
|
|
||||||
/// 백엔드가 요구하는 `yyyy-MM-dd`(NaiveDate) 형식으로 변환한다.
|
/// 백엔드가 요구하는 `yyyy-MM-dd`(NaiveDate) 형식으로 변환한다.
|
||||||
String _formatDate(DateTime value) {
|
String _formatDate(DateTime value) {
|
||||||
final iso = value.toIso8601String();
|
return _formatNaiveDate(value);
|
||||||
final separatorIndex = iso.indexOf('T');
|
|
||||||
if (separatorIndex == -1) {
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
return iso.substring(0, separatorIndex);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +229,14 @@ class StockTransactionApprovalInput {
|
|||||||
'approval_no': approvalNo,
|
'approval_no': approvalNo,
|
||||||
if (approvalStatusId != null) 'approval_status_id': approvalStatusId,
|
if (approvalStatusId != null) 'approval_status_id': approvalStatusId,
|
||||||
'requested_by_id': requestedById,
|
'requested_by_id': requestedById,
|
||||||
if (note != null && note!.trim().isNotEmpty) 'note': note,
|
'note': note?.trim(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _formatNaiveDate(DateTime value) {
|
||||||
|
final year = value.year.toString().padLeft(4, '0');
|
||||||
|
final month = value.month.toString().padLeft(2, '0');
|
||||||
|
final day = value.day.toString().padLeft(2, '0');
|
||||||
|
return '$year-$month-$day';
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import '../../../../core/network/api_error.dart';
|
|||||||
import '../../../../core/network/failure.dart';
|
import '../../../../core/network/failure.dart';
|
||||||
import '../../../../core/permissions/permission_manager.dart';
|
import '../../../../core/permissions/permission_manager.dart';
|
||||||
import '../../../../core/permissions/permission_resources.dart';
|
import '../../../../core/permissions/permission_resources.dart';
|
||||||
|
import '../../../auth/application/auth_service.dart';
|
||||||
|
import '../../../auth/domain/entities/auth_session.dart';
|
||||||
|
import '../../../auth/domain/entities/login_request.dart';
|
||||||
import '../../../masters/group/domain/entities/group.dart';
|
import '../../../masters/group/domain/entities/group.dart';
|
||||||
import '../../../masters/group/domain/repositories/group_repository.dart';
|
import '../../../masters/group/domain/repositories/group_repository.dart';
|
||||||
import '../../../masters/group_permission/application/permission_synchronizer.dart';
|
import '../../../masters/group_permission/application/permission_synchronizer.dart';
|
||||||
@@ -49,8 +52,6 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
final id = idController.text.trim();
|
final id = idController.text.trim();
|
||||||
final password = passwordController.text.trim();
|
final password = passwordController.text.trim();
|
||||||
|
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 600));
|
|
||||||
|
|
||||||
if (id.isEmpty || password.isEmpty) {
|
if (id.isEmpty || password.isEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
errorMessage = '아이디와 비밀번호를 모두 입력하세요.';
|
errorMessage = '아이디와 비밀번호를 모두 입력하세요.';
|
||||||
@@ -67,9 +68,19 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final authService = GetIt.I<AuthService>();
|
||||||
|
AuthSession? session;
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
try {
|
try {
|
||||||
await _synchronizePermissions();
|
session = await authService.login(
|
||||||
|
LoginRequest(
|
||||||
|
identifier: id,
|
||||||
|
password: password,
|
||||||
|
rememberMe: rememberMe,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await _applyPermissions(session);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final failure = Failure.from(error);
|
final failure = Failure.from(error);
|
||||||
@@ -317,24 +328,50 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
context.go(dashboardRoutePath);
|
context.go(dashboardRoutePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _synchronizePermissions() async {
|
Future<void> _applyPermissions(AuthSession session) async {
|
||||||
|
final manager = PermissionScope.of(context);
|
||||||
|
manager.clearServerPermissions();
|
||||||
|
|
||||||
|
final aggregated = <String, Set<PermissionAction>>{};
|
||||||
|
for (final permission in session.permissions) {
|
||||||
|
final map = permission.toPermissionMap();
|
||||||
|
for (final entry in map.entries) {
|
||||||
|
aggregated
|
||||||
|
.putIfAbsent(entry.key, () => <PermissionAction>{})
|
||||||
|
.addAll(entry.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (aggregated.isNotEmpty) {
|
||||||
|
manager.applyServerPermissions(aggregated);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _synchronizePermissions(groupId: session.user.primaryGroupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _synchronizePermissions({int? groupId}) async {
|
||||||
final manager = PermissionScope.of(context);
|
final manager = PermissionScope.of(context);
|
||||||
manager.clearServerPermissions();
|
manager.clearServerPermissions();
|
||||||
|
|
||||||
final groupRepository = GetIt.I<GroupRepository>();
|
final groupRepository = GetIt.I<GroupRepository>();
|
||||||
final defaultGroups = await groupRepository.list(
|
int? targetGroupId = groupId;
|
||||||
page: 1,
|
|
||||||
pageSize: 1,
|
|
||||||
isDefault: true,
|
|
||||||
);
|
|
||||||
Group? targetGroup = _firstGroupWithId(defaultGroups.items);
|
|
||||||
|
|
||||||
if (targetGroup == null) {
|
if (targetGroupId == null) {
|
||||||
final fallbackGroups = await groupRepository.list(page: 1, pageSize: 1);
|
final defaultGroups = await groupRepository.list(
|
||||||
targetGroup = _firstGroupWithId(fallbackGroups.items);
|
page: 1,
|
||||||
|
pageSize: 1,
|
||||||
|
isDefault: true,
|
||||||
|
);
|
||||||
|
var targetGroup = _firstGroupWithId(defaultGroups.items);
|
||||||
|
|
||||||
|
if (targetGroup == null) {
|
||||||
|
final fallbackGroups = await groupRepository.list(page: 1, pageSize: 1);
|
||||||
|
targetGroup = _firstGroupWithId(fallbackGroups.items);
|
||||||
|
}
|
||||||
|
targetGroupId = targetGroup?.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetGroup?.id == null) {
|
if (targetGroupId == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,8 +380,7 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
repository: permissionRepository,
|
repository: permissionRepository,
|
||||||
manager: manager,
|
manager: manager,
|
||||||
);
|
);
|
||||||
final groupId = targetGroup!.id!;
|
await synchronizer.syncForGroup(targetGroupId);
|
||||||
await synchronizer.syncForGroup(groupId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Group? _firstGroupWithId(List<Group> groups) {
|
Group? _firstGroupWithId(List<Group> groups) {
|
||||||
|
|||||||
@@ -66,9 +66,10 @@ class CustomerRepositoryRemote implements CustomerRepository {
|
|||||||
/// 고객 정보를 수정한다.
|
/// 고객 정보를 수정한다.
|
||||||
@override
|
@override
|
||||||
Future<Customer> update(int id, CustomerInput input) async {
|
Future<Customer> update(int id, CustomerInput input) async {
|
||||||
|
final payload = customerInputToJson(input)..['id'] = id;
|
||||||
final response = await _api.patch<Map<String, dynamic>>(
|
final response = await _api.patch<Map<String, dynamic>>(
|
||||||
'$_basePath/$id',
|
'$_basePath/$id',
|
||||||
data: customerInputToJson(input),
|
data: payload,
|
||||||
options: Options(responseType: ResponseType.json),
|
options: Options(responseType: ResponseType.json),
|
||||||
);
|
);
|
||||||
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
|
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
|
||||||
|
|||||||
@@ -63,9 +63,10 @@ class GroupRepositoryRemote implements GroupRepository {
|
|||||||
/// 그룹 정보를 수정한다.
|
/// 그룹 정보를 수정한다.
|
||||||
@override
|
@override
|
||||||
Future<Group> update(int id, GroupInput input) async {
|
Future<Group> update(int id, GroupInput input) async {
|
||||||
|
final payload = input.toPayload()..['id'] = id;
|
||||||
final response = await _api.patch<Map<String, dynamic>>(
|
final response = await _api.patch<Map<String, dynamic>>(
|
||||||
'$_basePath/$id',
|
'$_basePath/$id',
|
||||||
data: input.toPayload(),
|
data: payload,
|
||||||
options: Options(responseType: ResponseType.json),
|
options: Options(responseType: ResponseType.json),
|
||||||
);
|
);
|
||||||
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
|
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
|
||||||
|
|||||||
@@ -57,9 +57,10 @@ class GroupPermissionRepositoryRemote implements GroupPermissionRepository {
|
|||||||
/// 그룹 권한을 수정한다.
|
/// 그룹 권한을 수정한다.
|
||||||
@override
|
@override
|
||||||
Future<GroupPermission> update(int id, GroupPermissionInput input) async {
|
Future<GroupPermission> update(int id, GroupPermissionInput input) async {
|
||||||
|
final payload = input.toPayload()..['id'] = id;
|
||||||
final response = await _api.patch<Map<String, dynamic>>(
|
final response = await _api.patch<Map<String, dynamic>>(
|
||||||
'$_basePath/$id',
|
'$_basePath/$id',
|
||||||
data: input.toPayload(),
|
data: payload,
|
||||||
options: Options(responseType: ResponseType.json),
|
options: Options(responseType: ResponseType.json),
|
||||||
);
|
);
|
||||||
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
|
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
|
||||||
|
|||||||
@@ -56,9 +56,10 @@ class MenuRepositoryRemote implements MenuRepository {
|
|||||||
/// 메뉴 정보를 수정한다.
|
/// 메뉴 정보를 수정한다.
|
||||||
@override
|
@override
|
||||||
Future<MenuItem> update(int id, MenuInput input) async {
|
Future<MenuItem> update(int id, MenuInput input) async {
|
||||||
|
final payload = input.toPayload()..['id'] = id;
|
||||||
final response = await _api.patch<Map<String, dynamic>>(
|
final response = await _api.patch<Map<String, dynamic>>(
|
||||||
'$_basePath/$id',
|
'$_basePath/$id',
|
||||||
data: input.toPayload(),
|
data: payload,
|
||||||
options: Options(responseType: ResponseType.json),
|
options: Options(responseType: ResponseType.json),
|
||||||
);
|
);
|
||||||
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
|
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
|
||||||
|
|||||||
@@ -56,9 +56,10 @@ class ProductRepositoryRemote implements ProductRepository {
|
|||||||
/// 제품 정보를 수정한다.
|
/// 제품 정보를 수정한다.
|
||||||
@override
|
@override
|
||||||
Future<Product> update(int id, ProductInput input) async {
|
Future<Product> update(int id, ProductInput input) async {
|
||||||
|
final payload = productInputToJson(input)..['id'] = id;
|
||||||
final response = await _api.patch<Map<String, dynamic>>(
|
final response = await _api.patch<Map<String, dynamic>>(
|
||||||
'$_basePath/$id',
|
'$_basePath/$id',
|
||||||
data: productInputToJson(input),
|
data: payload,
|
||||||
options: Options(responseType: ResponseType.json),
|
options: Options(responseType: ResponseType.json),
|
||||||
);
|
);
|
||||||
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
|
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
|
||||||
|
|||||||
@@ -54,9 +54,10 @@ class UserRepositoryRemote implements UserRepository {
|
|||||||
/// 사용자 정보를 수정한다.
|
/// 사용자 정보를 수정한다.
|
||||||
@override
|
@override
|
||||||
Future<UserAccount> update(int id, UserInput input) async {
|
Future<UserAccount> update(int id, UserInput input) async {
|
||||||
|
final payload = input.toPayload()..['id'] = id;
|
||||||
final response = await _api.patch<Map<String, dynamic>>(
|
final response = await _api.patch<Map<String, dynamic>>(
|
||||||
'$_basePath/$id',
|
'$_basePath/$id',
|
||||||
data: input.toPayload(),
|
data: payload,
|
||||||
options: Options(responseType: ResponseType.json),
|
options: Options(responseType: ResponseType.json),
|
||||||
);
|
);
|
||||||
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
|
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
|
||||||
|
|||||||
@@ -50,9 +50,10 @@ class VendorRepositoryRemote implements VendorRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Vendor> update(int id, VendorInput input) async {
|
Future<Vendor> update(int id, VendorInput input) async {
|
||||||
|
final payload = vendorInputToJson(input)..['id'] = id;
|
||||||
final response = await _api.patch<Map<String, dynamic>>(
|
final response = await _api.patch<Map<String, dynamic>>(
|
||||||
'$_basePath/$id',
|
'$_basePath/$id',
|
||||||
data: vendorInputToJson(input),
|
data: payload,
|
||||||
options: Options(responseType: ResponseType.json),
|
options: Options(responseType: ResponseType.json),
|
||||||
);
|
);
|
||||||
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
|
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
|
||||||
|
|||||||
@@ -53,9 +53,10 @@ class WarehouseRepositoryRemote implements WarehouseRepository {
|
|||||||
/// 창고 정보를 수정한다.
|
/// 창고 정보를 수정한다.
|
||||||
@override
|
@override
|
||||||
Future<Warehouse> update(int id, WarehouseInput input) async {
|
Future<Warehouse> update(int id, WarehouseInput input) async {
|
||||||
|
final payload = warehouseInputToJson(input)..['id'] = id;
|
||||||
final response = await _api.patch<Map<String, dynamic>>(
|
final response = await _api.patch<Map<String, dynamic>>(
|
||||||
'$_basePath/$id',
|
'$_basePath/$id',
|
||||||
data: warehouseInputToJson(input),
|
data: payload,
|
||||||
options: Options(responseType: ResponseType.json),
|
options: Options(responseType: ResponseType.json),
|
||||||
);
|
);
|
||||||
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
|
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ class ReportingRepositoryRemote implements ReportingRepository {
|
|||||||
|
|
||||||
Map<String, dynamic> _buildTransactionQuery(ReportExportRequest request) {
|
Map<String, dynamic> _buildTransactionQuery(ReportExportRequest request) {
|
||||||
return {
|
return {
|
||||||
'date_from': _formatDateOnly(request.from),
|
'from': _formatDateOnly(request.from),
|
||||||
'date_to': _formatDateOnly(request.to),
|
'to': _formatDateOnly(request.to),
|
||||||
'format': request.format.apiValue,
|
'format': request.format.apiValue,
|
||||||
if (request.transactionStatusId != null)
|
if (request.transactionStatusId != null)
|
||||||
'transaction_status_id': request.transactionStatusId,
|
'transaction_status_id': request.transactionStatusId,
|
||||||
@@ -63,6 +63,8 @@ class ReportingRepositoryRemote implements ReportingRepository {
|
|||||||
'from': request.from.toIso8601String(),
|
'from': request.from.toIso8601String(),
|
||||||
'to': request.to.toIso8601String(),
|
'to': request.to.toIso8601String(),
|
||||||
'format': request.format.apiValue,
|
'format': request.format.apiValue,
|
||||||
|
if (request.transactionStatusId != null)
|
||||||
|
'transaction_status_id': request.transactionStatusId,
|
||||||
if (request.approvalStatusId != null)
|
if (request.approvalStatusId != null)
|
||||||
'approval_status_id': request.approvalStatusId,
|
'approval_status_id': request.approvalStatusId,
|
||||||
if (request.requestedById != null)
|
if (request.requestedById != null)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class _PostalSearchPageState extends State<PostalSearchPage> {
|
|||||||
child: ShadCard(
|
child: ShadCard(
|
||||||
title: Text('우편번호 검색 모달 미리보기', style: theme.textTheme.h3),
|
title: Text('우편번호 검색 모달 미리보기', style: theme.textTheme.h3),
|
||||||
description: Text(
|
description: Text(
|
||||||
'검색 버튼을 눌러 모달 UI를 확인하세요. 검색 API 연동은 이후 단계에서 진행됩니다.',
|
'검색 버튼을 눌러 실제 우편번호 API 결과와 모달 UI를 확인하세요.',
|
||||||
style: theme.textTheme.muted,
|
style: theme.textTheme.muted,
|
||||||
),
|
),
|
||||||
footer: Row(
|
footer: Row(
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import 'core/network/api_client.dart';
|
|||||||
import 'core/network/api_error.dart';
|
import 'core/network/api_error.dart';
|
||||||
import 'core/network/interceptors/auth_interceptor.dart';
|
import 'core/network/interceptors/auth_interceptor.dart';
|
||||||
import 'core/services/token_storage.dart';
|
import 'core/services/token_storage.dart';
|
||||||
|
import 'features/auth/application/auth_service.dart';
|
||||||
|
import 'features/auth/data/repositories/auth_repository_remote.dart';
|
||||||
|
import 'features/auth/domain/repositories/auth_repository.dart';
|
||||||
|
import 'features/dashboard/data/repositories/dashboard_repository_remote.dart';
|
||||||
|
import 'features/dashboard/domain/repositories/dashboard_repository.dart';
|
||||||
import 'features/inventory/lookups/data/repositories/inventory_lookup_repository_remote.dart';
|
import 'features/inventory/lookups/data/repositories/inventory_lookup_repository_remote.dart';
|
||||||
import 'features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
|
import 'features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
|
||||||
import 'features/inventory/transactions/data/repositories/stock_transaction_repository_remote.dart';
|
import 'features/inventory/transactions/data/repositories/stock_transaction_repository_remote.dart';
|
||||||
@@ -69,7 +74,16 @@ Future<void> initInjection({
|
|||||||
sl.registerLazySingleton<TokenStorage>(() => tokenStorage);
|
sl.registerLazySingleton<TokenStorage>(() => tokenStorage);
|
||||||
sl.registerLazySingleton<ApiErrorMapper>(ApiErrorMapper.new);
|
sl.registerLazySingleton<ApiErrorMapper>(ApiErrorMapper.new);
|
||||||
|
|
||||||
final authInterceptor = AuthInterceptor(tokenStorage: tokenStorage, dio: dio);
|
final authInterceptor = AuthInterceptor(
|
||||||
|
tokenStorage: tokenStorage,
|
||||||
|
dio: dio,
|
||||||
|
onRefresh: () {
|
||||||
|
if (!sl.isRegistered<AuthService>()) {
|
||||||
|
return Future<TokenPair?>.value(null);
|
||||||
|
}
|
||||||
|
return sl<AuthService>().refreshForInterceptor();
|
||||||
|
},
|
||||||
|
);
|
||||||
dio.interceptors.add(authInterceptor);
|
dio.interceptors.add(authInterceptor);
|
||||||
|
|
||||||
// 개발용 로거는 필요 시 추가 (pretty_dio_logger 등)
|
// 개발용 로거는 필요 시 추가 (pretty_dio_logger 등)
|
||||||
@@ -80,6 +94,21 @@ Future<void> initInjection({
|
|||||||
() => ApiClient(dio: dio, errorMapper: sl<ApiErrorMapper>()),
|
() => ApiClient(dio: dio, errorMapper: sl<ApiErrorMapper>()),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 인증 서비스 등록
|
||||||
|
sl.registerLazySingleton<AuthRepository>(
|
||||||
|
() => AuthRepositoryRemote(apiClient: sl<ApiClient>()),
|
||||||
|
);
|
||||||
|
sl.registerLazySingleton<AuthService>(
|
||||||
|
() => AuthService(
|
||||||
|
repository: sl<AuthRepository>(),
|
||||||
|
tokenStorage: sl<TokenStorage>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
sl.registerLazySingleton<DashboardRepository>(
|
||||||
|
() => DashboardRepositoryRemote(apiClient: sl<ApiClient>()),
|
||||||
|
);
|
||||||
|
|
||||||
// 리포지토리 등록 (예: 벤더)
|
// 리포지토리 등록 (예: 벤더)
|
||||||
sl.registerLazySingleton<VendorRepository>(
|
sl.registerLazySingleton<VendorRepository>(
|
||||||
() => VendorRepositoryRemote(apiClient: sl<ApiClient>()),
|
() => VendorRepositoryRemote(apiClient: sl<ApiClient>()),
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ class _StubApprovalRepository implements ApprovalRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Approval> create(ApprovalInput input) {
|
Future<Approval> create(ApprovalCreateInput input) {
|
||||||
throw UnimplementedError();
|
throw UnimplementedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +260,7 @@ class _StubApprovalRepository implements ApprovalRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Approval> update(int id, ApprovalInput input) {
|
Future<Approval> update(ApprovalUpdateInput input) {
|
||||||
throw UnimplementedError();
|
throw UnimplementedError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,104 @@ void main() {
|
|||||||
expect(query['include'], 'steps,histories');
|
expect(query['include'], 'steps,histories');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('create는 필수 필드를 전달한다', () async {
|
||||||
|
const path = '/api/v1/approvals';
|
||||||
|
when(
|
||||||
|
() => apiClient.post<Map<String, dynamic>>(
|
||||||
|
path,
|
||||||
|
data: any(named: 'data'),
|
||||||
|
options: any(named: 'options'),
|
||||||
|
cancelToken: any(named: 'cancelToken'),
|
||||||
|
),
|
||||||
|
).thenAnswer(
|
||||||
|
(_) async => Response<Map<String, dynamic>>(
|
||||||
|
data: {
|
||||||
|
'data': {
|
||||||
|
'id': 5001,
|
||||||
|
'approval_no': 'APP-2025-0001',
|
||||||
|
'transaction_id': 9001,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
statusCode: 201,
|
||||||
|
requestOptions: RequestOptions(path: path),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final input = ApprovalCreateInput(
|
||||||
|
transactionId: 9001,
|
||||||
|
approvalNo: 'APP-2025-0001',
|
||||||
|
approvalStatusId: 1,
|
||||||
|
requestedById: 7,
|
||||||
|
note: ' 신규 결재 ',
|
||||||
|
);
|
||||||
|
|
||||||
|
await repository.create(input);
|
||||||
|
|
||||||
|
final captured = verify(
|
||||||
|
() => apiClient.post<Map<String, dynamic>>(
|
||||||
|
captureAny(),
|
||||||
|
data: captureAny(named: 'data'),
|
||||||
|
options: any(named: 'options'),
|
||||||
|
cancelToken: any(named: 'cancelToken'),
|
||||||
|
),
|
||||||
|
).captured;
|
||||||
|
|
||||||
|
expect(captured.first, equals(path));
|
||||||
|
final payload = captured[1] as Map<String, dynamic>;
|
||||||
|
expect(payload['transaction_id'], 9001);
|
||||||
|
expect(payload['approval_no'], 'APP-2025-0001');
|
||||||
|
expect(payload['approval_status_id'], 1);
|
||||||
|
expect(payload['requested_by_id'], 7);
|
||||||
|
expect(payload['note'], '신규 결재');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update는 id를 포함해 패치를 수행한다', () async {
|
||||||
|
const path = '/api/v1/approvals/5001';
|
||||||
|
when(
|
||||||
|
() => apiClient.patch<Map<String, dynamic>>(
|
||||||
|
path,
|
||||||
|
data: any(named: 'data'),
|
||||||
|
options: any(named: 'options'),
|
||||||
|
cancelToken: any(named: 'cancelToken'),
|
||||||
|
),
|
||||||
|
).thenAnswer(
|
||||||
|
(_) async => Response<Map<String, dynamic>>(
|
||||||
|
data: {
|
||||||
|
'data': {
|
||||||
|
'id': 5001,
|
||||||
|
'approval_no': 'APP-2025-0001',
|
||||||
|
'approval_status': {'id': 2, 'status_name': '진행중'},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
statusCode: 200,
|
||||||
|
requestOptions: RequestOptions(path: path),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final input = ApprovalUpdateInput(
|
||||||
|
id: 5001,
|
||||||
|
approvalStatusId: 2,
|
||||||
|
note: '보류',
|
||||||
|
);
|
||||||
|
|
||||||
|
await repository.update(input);
|
||||||
|
|
||||||
|
final captured = verify(
|
||||||
|
() => apiClient.patch<Map<String, dynamic>>(
|
||||||
|
captureAny(),
|
||||||
|
data: captureAny(named: 'data'),
|
||||||
|
options: any(named: 'options'),
|
||||||
|
cancelToken: any(named: 'cancelToken'),
|
||||||
|
),
|
||||||
|
).captured;
|
||||||
|
|
||||||
|
expect(captured.first, equals(path));
|
||||||
|
final payload = captured[1] as Map<String, dynamic>;
|
||||||
|
expect(payload['id'], 5001);
|
||||||
|
expect(payload['approval_status_id'], 2);
|
||||||
|
expect(payload['note'], '보류');
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> buildStep({
|
Map<String, dynamic> buildStep({
|
||||||
required int id,
|
required int id,
|
||||||
required int order,
|
required int order,
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import 'package:superport_v2/features/inventory/lookups/domain/repositories/inve
|
|||||||
class _MockApprovalRepository extends Mock implements ApprovalRepository {}
|
class _MockApprovalRepository extends Mock implements ApprovalRepository {}
|
||||||
|
|
||||||
/// Approval 생성 요청을 대체하기 위한 가짜 입력.
|
/// Approval 생성 요청을 대체하기 위한 가짜 입력.
|
||||||
class _FakeApprovalInput extends Fake implements ApprovalInput {}
|
class _FakeApprovalCreateInput extends Fake implements ApprovalCreateInput {}
|
||||||
|
|
||||||
|
/// Approval 수정 요청을 대체하기 위한 가짜 입력.
|
||||||
|
class _FakeApprovalUpdateInput extends Fake implements ApprovalUpdateInput {}
|
||||||
|
|
||||||
/// 단계 행위 요청을 대체하기 위한 가짜 입력.
|
/// 단계 행위 요청을 대체하기 위한 가짜 입력.
|
||||||
class _FakeStepActionInput extends Fake implements ApprovalStepActionInput {}
|
class _FakeStepActionInput extends Fake implements ApprovalStepActionInput {}
|
||||||
@@ -68,7 +71,8 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setUpAll(() {
|
setUpAll(() {
|
||||||
registerFallbackValue(_FakeApprovalInput());
|
registerFallbackValue(_FakeApprovalCreateInput());
|
||||||
|
registerFallbackValue(_FakeApprovalUpdateInput());
|
||||||
registerFallbackValue(_FakeStepActionInput());
|
registerFallbackValue(_FakeStepActionInput());
|
||||||
registerFallbackValue(_FakeStepAssignmentInput());
|
registerFallbackValue(_FakeStepAssignmentInput());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import 'package:superport_v2/features/inventory/lookups/domain/repositories/inve
|
|||||||
|
|
||||||
class _MockApprovalRepository extends Mock implements ApprovalRepository {}
|
class _MockApprovalRepository extends Mock implements ApprovalRepository {}
|
||||||
|
|
||||||
class _FakeApprovalInput extends Fake implements ApprovalInput {}
|
class _FakeApprovalCreateInput extends Fake implements ApprovalCreateInput {}
|
||||||
|
|
||||||
|
class _FakeApprovalUpdateInput extends Fake implements ApprovalUpdateInput {}
|
||||||
|
|
||||||
class _MockApprovalTemplateRepository extends Mock
|
class _MockApprovalTemplateRepository extends Mock
|
||||||
implements ApprovalTemplateRepository {}
|
implements ApprovalTemplateRepository {}
|
||||||
@@ -45,7 +47,8 @@ void main() {
|
|||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
setUpAll(() {
|
setUpAll(() {
|
||||||
registerFallbackValue(_FakeApprovalInput());
|
registerFallbackValue(_FakeApprovalCreateInput());
|
||||||
|
registerFallbackValue(_FakeApprovalUpdateInput());
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() async {
|
tearDown(() async {
|
||||||
|
|||||||
@@ -6,10 +6,16 @@ import 'package:mocktail/mocktail.dart';
|
|||||||
|
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
|
import 'package:superport_v2/core/services/token_storage.dart';
|
||||||
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/constants/app_sections.dart';
|
import 'package:superport_v2/core/constants/app_sections.dart';
|
||||||
import 'package:superport_v2/core/permissions/permission_manager.dart';
|
import 'package:superport_v2/core/permissions/permission_manager.dart';
|
||||||
import 'package:superport_v2/core/permissions/permission_resources.dart';
|
import 'package:superport_v2/core/permissions/permission_resources.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';
|
||||||
|
import 'package:superport_v2/features/auth/domain/entities/login_request.dart';
|
||||||
|
import 'package:superport_v2/features/auth/domain/repositories/auth_repository.dart';
|
||||||
import 'package:superport_v2/features/login/presentation/pages/login_page.dart';
|
import 'package:superport_v2/features/login/presentation/pages/login_page.dart';
|
||||||
import 'package:superport_v2/features/masters/group/domain/entities/group.dart';
|
import 'package:superport_v2/features/masters/group/domain/entities/group.dart';
|
||||||
import 'package:superport_v2/features/masters/group/domain/repositories/group_repository.dart';
|
import 'package:superport_v2/features/masters/group/domain/repositories/group_repository.dart';
|
||||||
@@ -21,9 +27,69 @@ class _MockGroupRepository extends Mock implements GroupRepository {}
|
|||||||
class _MockGroupPermissionRepository extends Mock
|
class _MockGroupPermissionRepository extends Mock
|
||||||
implements GroupPermissionRepository {}
|
implements GroupPermissionRepository {}
|
||||||
|
|
||||||
|
class _MockAuthRepository extends Mock implements AuthRepository {}
|
||||||
|
|
||||||
|
class _FakeTokenStorage implements TokenStorage {
|
||||||
|
String? _accessToken;
|
||||||
|
String? _refreshToken;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clear() async {
|
||||||
|
_accessToken = null;
|
||||||
|
_refreshToken = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> readAccessToken() async => _accessToken;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> readRefreshToken() async => _refreshToken;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> writeAccessToken(String? token) async {
|
||||||
|
_accessToken = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> writeRefreshToken(String? token) async {
|
||||||
|
_refreshToken = token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUpAll(() {
|
||||||
|
registerFallbackValue(
|
||||||
|
LoginRequest(identifier: '', password: '', rememberMe: false),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
late _MockAuthRepository authRepository;
|
||||||
|
late _FakeTokenStorage tokenStorage;
|
||||||
|
late AuthService authService;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
authRepository = _MockAuthRepository();
|
||||||
|
tokenStorage = _FakeTokenStorage();
|
||||||
|
authService = AuthService(
|
||||||
|
repository: authRepository,
|
||||||
|
tokenStorage: tokenStorage,
|
||||||
|
);
|
||||||
|
final sampleSession = AuthSession(
|
||||||
|
accessToken: 'access-token',
|
||||||
|
refreshToken: 'refresh-token',
|
||||||
|
expiresAt: DateTime.now().add(const Duration(hours: 1)),
|
||||||
|
user: const AuthenticatedUser(id: 1, name: '테스터'),
|
||||||
|
permissions: const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
GetIt.I.registerSingleton<AuthService>(authService);
|
||||||
|
|
||||||
|
when(() => authRepository.login(any())).thenAnswer((_) async => sampleSession);
|
||||||
|
when(() => authRepository.refresh(any())).thenThrow(UnimplementedError());
|
||||||
|
});
|
||||||
|
|
||||||
tearDown(() async {
|
tearDown(() async {
|
||||||
await GetIt.I.reset();
|
await GetIt.I.reset();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -98,8 +98,8 @@ void main() {
|
|||||||
|
|
||||||
expect(captured.first, equals(path));
|
expect(captured.first, equals(path));
|
||||||
final query = captured[1] as Map<String, dynamic>;
|
final query = captured[1] as Map<String, dynamic>;
|
||||||
expect(query['date_from'], '2024-01-01');
|
expect(query['from'], '2024-01-01');
|
||||||
expect(query['date_to'], '2024-01-31');
|
expect(query['to'], '2024-01-31');
|
||||||
expect(query['format'], 'xlsx');
|
expect(query['format'], 'xlsx');
|
||||||
expect(query['transaction_status_id'], 3);
|
expect(query['transaction_status_id'], 3);
|
||||||
expect(query['approval_status_id'], 7);
|
expect(query['approval_status_id'], 7);
|
||||||
@@ -143,10 +143,69 @@ void main() {
|
|||||||
|
|
||||||
final result = await repository.exportApprovals(request);
|
final result = await repository.exportApprovals(request);
|
||||||
|
|
||||||
|
final captured = verify(
|
||||||
|
() => apiClient.get<Uint8List>(
|
||||||
|
captureAny(),
|
||||||
|
query: captureAny(named: 'query'),
|
||||||
|
options: any(named: 'options'),
|
||||||
|
cancelToken: any(named: 'cancelToken'),
|
||||||
|
),
|
||||||
|
).captured;
|
||||||
|
|
||||||
|
expect(captured.first, equals(path));
|
||||||
|
final query = captured[1] as Map<String, dynamic>;
|
||||||
|
expect(query['from'], DateTime(2024, 2, 1).toIso8601String());
|
||||||
|
expect(query['to'], DateTime(2024, 2, 15).toIso8601String());
|
||||||
|
expect(query['format'], 'pdf');
|
||||||
|
expect(query.containsKey('transaction_status_id'), isFalse);
|
||||||
expect(result.hasBytes, isTrue);
|
expect(result.hasBytes, isTrue);
|
||||||
expect(result.bytes, isNotNull);
|
expect(result.bytes, isNotNull);
|
||||||
expect(result.filename, 'approval.pdf');
|
expect(result.filename, 'approval.pdf');
|
||||||
expect(result.mimeType, 'application/pdf');
|
expect(result.mimeType, 'application/pdf');
|
||||||
expect(result.hasDownloadUrl, isFalse);
|
expect(result.hasDownloadUrl, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('exportApprovals는 transaction_status_id 파라미터를 전달한다', () async {
|
||||||
|
const path = '/api/v1/reports/approvals/export';
|
||||||
|
when(
|
||||||
|
() => apiClient.get<Uint8List>(
|
||||||
|
path,
|
||||||
|
query: any(named: 'query'),
|
||||||
|
options: any(named: 'options'),
|
||||||
|
cancelToken: any(named: 'cancelToken'),
|
||||||
|
),
|
||||||
|
).thenAnswer(
|
||||||
|
(_) async => binaryResponse(
|
||||||
|
path,
|
||||||
|
bytes: [4, 5, 6],
|
||||||
|
filename: 'approval.xlsx',
|
||||||
|
mimeType:
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final request = ReportExportRequest(
|
||||||
|
from: DateTime(2024, 4, 1),
|
||||||
|
to: DateTime(2024, 4, 30),
|
||||||
|
format: ReportExportFormat.xlsx,
|
||||||
|
transactionStatusId: 2,
|
||||||
|
approvalStatusId: 4,
|
||||||
|
);
|
||||||
|
|
||||||
|
await repository.exportApprovals(request);
|
||||||
|
|
||||||
|
final captured = verify(
|
||||||
|
() => apiClient.get<Uint8List>(
|
||||||
|
captureAny(),
|
||||||
|
query: captureAny(named: 'query'),
|
||||||
|
options: any(named: 'options'),
|
||||||
|
cancelToken: any(named: 'cancelToken'),
|
||||||
|
),
|
||||||
|
).captured;
|
||||||
|
|
||||||
|
expect(captured.first, equals(path));
|
||||||
|
final query = captured[1] as Map<String, dynamic>;
|
||||||
|
expect(query['transaction_status_id'], 2);
|
||||||
|
expect(query['approval_status_id'], 4);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,19 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
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/config/environment.dart';
|
import 'package:superport_v2/core/config/environment.dart';
|
||||||
import 'package:superport_v2/core/constants/app_sections.dart';
|
import 'package:superport_v2/core/constants/app_sections.dart';
|
||||||
import 'package:superport_v2/core/permissions/permission_manager.dart';
|
import 'package:superport_v2/core/permissions/permission_manager.dart';
|
||||||
import 'package:superport_v2/core/theme/superport_shad_theme.dart';
|
import 'package:superport_v2/core/theme/superport_shad_theme.dart';
|
||||||
|
import 'package:superport_v2/core/services/token_storage.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';
|
||||||
|
import 'package:superport_v2/features/auth/domain/entities/login_request.dart';
|
||||||
|
import 'package:superport_v2/features/auth/domain/repositories/auth_repository.dart';
|
||||||
import 'package:superport_v2/features/login/presentation/pages/login_page.dart';
|
import 'package:superport_v2/features/login/presentation/pages/login_page.dart';
|
||||||
import 'package:superport_v2/features/masters/group/domain/entities/group.dart';
|
import 'package:superport_v2/features/masters/group/domain/entities/group.dart';
|
||||||
import 'package:superport_v2/features/masters/group/domain/repositories/group_repository.dart';
|
import 'package:superport_v2/features/masters/group/domain/repositories/group_repository.dart';
|
||||||
@@ -222,9 +229,74 @@ class _StubGroupPermissionRepository implements GroupPermissionRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _MockAuthRepository extends Mock implements AuthRepository {}
|
||||||
|
|
||||||
|
class _FakeTokenStorage implements TokenStorage {
|
||||||
|
String? _accessToken;
|
||||||
|
String? _refreshToken;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clear() async {
|
||||||
|
_accessToken = null;
|
||||||
|
_refreshToken = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> readAccessToken() async => _accessToken;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> readRefreshToken() async => _refreshToken;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> writeAccessToken(String? token) async {
|
||||||
|
_accessToken = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> writeRefreshToken(String? token) async {
|
||||||
|
_refreshToken = token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthSession _buildSampleSession() {
|
||||||
|
return const AuthSession(
|
||||||
|
accessToken: 'access-token',
|
||||||
|
refreshToken: 'refresh-token',
|
||||||
|
expiresAt: null,
|
||||||
|
user: AuthenticatedUser(id: 1, name: '테스터'),
|
||||||
|
permissions: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _registerAuthService(
|
||||||
|
_MockAuthRepository repository,
|
||||||
|
_FakeTokenStorage storage,
|
||||||
|
) {
|
||||||
|
final service = AuthService(repository: repository, tokenStorage: storage);
|
||||||
|
when(() => repository.login(any())).thenAnswer((_) async => _buildSampleSession());
|
||||||
|
when(() => repository.refresh(any())).thenThrow(UnimplementedError());
|
||||||
|
GetIt.I.registerSingleton<AuthService>(service);
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUpAll(() {
|
||||||
|
registerFallbackValue(
|
||||||
|
LoginRequest(identifier: '', password: '', rememberMe: false),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
late _MockAuthRepository authRepository;
|
||||||
|
late _FakeTokenStorage tokenStorage;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
await GetIt.I.reset();
|
||||||
|
authRepository = _MockAuthRepository();
|
||||||
|
tokenStorage = _FakeTokenStorage();
|
||||||
|
_registerAuthService(authRepository, tokenStorage);
|
||||||
|
});
|
||||||
|
|
||||||
setUpAll(() async {
|
setUpAll(() async {
|
||||||
await Environment.initialize();
|
await Environment.initialize();
|
||||||
});
|
});
|
||||||
@@ -240,12 +312,10 @@ void main() {
|
|||||||
view.resetDevicePixelRatio();
|
view.resetDevicePixelRatio();
|
||||||
});
|
});
|
||||||
|
|
||||||
await GetIt.I.reset();
|
|
||||||
GetIt.I.registerSingleton<GroupRepository>(_StubGroupRepository());
|
GetIt.I.registerSingleton<GroupRepository>(_StubGroupRepository());
|
||||||
GetIt.I.registerSingleton<GroupPermissionRepository>(
|
GetIt.I.registerSingleton<GroupPermissionRepository>(
|
||||||
_StubGroupPermissionRepository(),
|
_StubGroupPermissionRepository(),
|
||||||
);
|
);
|
||||||
addTearDown(() async => GetIt.I.reset());
|
|
||||||
|
|
||||||
final router = _createTestRouter();
|
final router = _createTestRouter();
|
||||||
await tester.pumpWidget(_TestApp(router: router));
|
await tester.pumpWidget(_TestApp(router: router));
|
||||||
|
|||||||
Reference in New Issue
Block a user