전역 구조 리팩터링 및 테스트 확장
This commit is contained in:
105
doc/input_widget_guide.md
Normal file
105
doc/input_widget_guide.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# 입력 위젯 가이드
|
||||
|
||||
Superport v2의 폼 UI는 shadcn_ui 구성 요소를 기반으로 하며, 아래 지침을 따를 때 레이아웃과 상호작용이 일관되게 유지된다.
|
||||
|
||||
## 1. 기본 컨테이너 — `SuperportFormField`
|
||||
- 라벨/필수 표시/보조 설명/에러 메시지를 하나의 빌딩 블록으로 묶는다.
|
||||
- 라벨은 좌측 정렬, 12pt, 필수 항목은 `*`(파괴색)으로 표시한다.
|
||||
- 자식 위젯은 `ShadInput`, `ShadSelect`, `SuperportDatePickerButton` 등 어떤 입력 요소든 가능하다.
|
||||
- 에러 문구는 상단 Validator에서 내려오는 한글 메시지를 그대로 사용한다.
|
||||
|
||||
```dart
|
||||
SuperportFormField(
|
||||
label: '창고',
|
||||
required: true,
|
||||
caption: '입고가 진행될 창고를 선택하세요.',
|
||||
errorText: state.errorMessage,
|
||||
child: ShadSelect<String>(
|
||||
initialValue: controller.selectedWarehouse,
|
||||
options: warehouses.map((w) => ShadOption(value: w.id, child: Text(w.name))).toList(),
|
||||
onChanged: controller.onWarehouseChanged,
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
## 2. 텍스트 입력 — `SuperportTextInput`
|
||||
- `ShadInput`에 공통 프리셋을 적용한 래퍼.
|
||||
- 플레이스홀더는 한글 문장형으로 작성하고, 검색 필드라면 돋보기 아이콘을 `leading`으로 배치한다.
|
||||
- 여러 줄 입력은 `maxLines` 변경만으로 처리한다.
|
||||
|
||||
```dart
|
||||
SuperportFormField(
|
||||
label: '비고',
|
||||
child: SuperportTextInput(
|
||||
controller: remarkController,
|
||||
placeholder: const Text('추가 설명을 입력하세요.'),
|
||||
maxLines: 3,
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
## 3. 선택 컴포넌트 — `ShadSelect`
|
||||
- 단일 선택은 `ShadSelect<T>`를 그대로 사용하고, `SuperportFormField`로 라벨만 감싼다.
|
||||
- 다중 선택이 필요한 경우 `ShadSelect.multiple` 과 토큰(UiChip) 스타일을 조합한다.
|
||||
- 최초 옵션은 `전체`/`선택하세요`처럼 명확한 기본값을 제공한다.
|
||||
|
||||
```dart
|
||||
SuperportFormField(
|
||||
label: '상태',
|
||||
required: true,
|
||||
child: ShadSelect<OrderStatus?>(
|
||||
initialValue: controller.pendingStatus,
|
||||
selectedOptionBuilder: (_, value) => Text(value?.label ?? '전체 상태'),
|
||||
options: [
|
||||
const ShadOption(value: null, child: Text('전체 상태')),
|
||||
for (final status in OrderStatus.values)
|
||||
ShadOption(value: status, child: Text(status.label)),
|
||||
],
|
||||
onChanged: controller.onStatusChanged,
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
## 4. 토글 — `SuperportSwitchField`
|
||||
- 스위치 단독 사용 시 라벨·캡션 레이아웃을 제공한다.
|
||||
- 접근성 관점에서 토글 설명은 문장형으로 작성한다.
|
||||
|
||||
```dart
|
||||
SuperportSwitchField(
|
||||
label: '파트너사 전용',
|
||||
value: controller.isPartnerOnly,
|
||||
onChanged: controller.onPartnerOnlyChanged,
|
||||
caption: '활성화 시 파트너사만 접근할 수 있습니다.',
|
||||
);
|
||||
```
|
||||
|
||||
## 5. 날짜/기간 — `SuperportDatePickerButton`
|
||||
- 단일 날짜는 `SuperportDatePickerButton`, 기간은 `SuperportDateRangePickerButton`을 사용한다.
|
||||
- 포맷은 기본적으로 `yyyy-MM-dd`, 필요 시 `dateFormat`으로 주입.
|
||||
- 기간 선택은 `firstDate`/`lastDate` 범위를 명시해 엣지 케이스를 제한한다.
|
||||
|
||||
```dart
|
||||
SuperportFormField(
|
||||
label: '처리 기간',
|
||||
child: SuperportDateRangePickerButton(
|
||||
value: controller.pendingRange,
|
||||
onChanged: controller.onRangeChanged,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2030),
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
## 6. 검증 메시지
|
||||
- Validator는 필수 오류 → 형식 오류 → 업무 규칙 순서로 확인하고, 메시지는 `SuperportFormField.errorText`로 전달한다.
|
||||
- 포커스 이동 시 즉시 에러를 표시하며, 성공 시 `caption`으로 가이드를 남겨 재입력을 돕는다.
|
||||
|
||||
## 7. 레이아웃
|
||||
- 가로 240px/500px 프리셋은 `SizedBox`로 감싸 사용하며, 반응형 환경에서는 `ResponsiveLayoutSlot`(섹션 13 참조)을 이용한다.
|
||||
- 두 줄 이상 배치 시 `Wrap` + `spacing:16`/`runSpacing:16`을 기본으로 한다.
|
||||
|
||||
## 8. 샘플 코드 경로
|
||||
- `lib/widgets/components/form_field.dart`
|
||||
- `lib/features/inventory/inbound/presentation/pages/inbound_page.dart` — 입고 등록 모달
|
||||
|
||||
위 가이드를 준수하면 폼 간 스타일과 상호작용 규칙을 동일하게 유지할 수 있다.
|
||||
@@ -18,12 +18,17 @@ class Environment {
|
||||
/// 프로덕션 여부
|
||||
static late final bool isProduction;
|
||||
|
||||
static final Map<String, Set<String>> _permissions = {};
|
||||
|
||||
/// 환경 초기화
|
||||
///
|
||||
/// - 기본 환경은 development이며, `ENV` dart-define 으로 변경 가능
|
||||
/// - 해당 환경의 .env 파일을 로드하고 핵심 값을 추출한다.
|
||||
static Future<void> initialize() async {
|
||||
const envFromDefine = String.fromEnvironment('ENV', defaultValue: 'development');
|
||||
const envFromDefine = String.fromEnvironment(
|
||||
'ENV',
|
||||
defaultValue: 'development',
|
||||
);
|
||||
envName = envFromDefine.toLowerCase();
|
||||
isProduction = envName == 'production';
|
||||
|
||||
@@ -46,6 +51,7 @@ class Environment {
|
||||
}
|
||||
|
||||
baseUrl = dotenv.maybeGet('API_BASE_URL') ?? 'http://localhost:8080';
|
||||
_loadPermissions();
|
||||
}
|
||||
|
||||
/// 기능 플래그 조회 (기본 false)
|
||||
@@ -67,4 +73,32 @@ class Environment {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
static void _loadPermissions() {
|
||||
_permissions.clear();
|
||||
for (final entry in dotenv.env.entries) {
|
||||
const prefix = 'PERMISSION__';
|
||||
if (!entry.key.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
final resource = entry.key.substring(prefix.length).toLowerCase();
|
||||
final values = entry.value
|
||||
.split(',')
|
||||
.map((token) => token.trim().toLowerCase())
|
||||
.where((token) => token.isNotEmpty)
|
||||
.toSet();
|
||||
_permissions[resource] = values;
|
||||
}
|
||||
}
|
||||
|
||||
static bool hasPermission(String resource, String action) {
|
||||
final actions = _permissions[resource.toLowerCase()];
|
||||
if (actions == null || actions.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
if (actions.contains('all')) {
|
||||
return true;
|
||||
}
|
||||
return actions.contains(action.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
|
||||
class AppPageDescriptor {
|
||||
const AppPageDescriptor({
|
||||
@@ -32,7 +32,7 @@ const appSections = <AppSectionDescriptor>[
|
||||
AppPageDescriptor(
|
||||
path: dashboardRoutePath,
|
||||
label: '대시보드',
|
||||
icon: LucideIcons.layoutDashboard,
|
||||
icon: lucide.LucideIcons.layoutDashboard,
|
||||
summary: '오늘 입고/출고, 결재 대기, 최근 트랜잭션을 한 화면에서 확인합니다.',
|
||||
),
|
||||
],
|
||||
@@ -43,19 +43,19 @@ const appSections = <AppSectionDescriptor>[
|
||||
AppPageDescriptor(
|
||||
path: '/inventory/inbound',
|
||||
label: '입고',
|
||||
icon: LucideIcons.packagePlus,
|
||||
icon: lucide.LucideIcons.packagePlus,
|
||||
summary: '입고 처리 기본정보와 라인 품목을 등록하고 검토합니다.',
|
||||
),
|
||||
AppPageDescriptor(
|
||||
path: '/inventory/outbound',
|
||||
label: '출고',
|
||||
icon: LucideIcons.packageMinus,
|
||||
icon: lucide.LucideIcons.packageMinus,
|
||||
summary: '출고 품목, 고객사 연결, 상태 변경을 관리합니다.',
|
||||
),
|
||||
AppPageDescriptor(
|
||||
path: '/inventory/rental',
|
||||
label: '대여',
|
||||
icon: LucideIcons.handshake,
|
||||
icon: lucide.LucideIcons.handshake,
|
||||
summary: '대여/반납 구분과 반납예정일을 포함한 대여 흐름입니다.',
|
||||
),
|
||||
],
|
||||
@@ -66,49 +66,49 @@ const appSections = <AppSectionDescriptor>[
|
||||
AppPageDescriptor(
|
||||
path: '/masters/vendors',
|
||||
label: '제조사 관리',
|
||||
icon: LucideIcons.factory,
|
||||
icon: lucide.LucideIcons.factory,
|
||||
summary: '벤더코드, 명칭, 사용여부 등을 유지합니다.',
|
||||
),
|
||||
AppPageDescriptor(
|
||||
path: '/masters/products',
|
||||
label: '장비 모델 관리',
|
||||
icon: LucideIcons.box,
|
||||
icon: lucide.LucideIcons.box,
|
||||
summary: '제품코드, 제조사, 단위 정보를 관리합니다.',
|
||||
),
|
||||
AppPageDescriptor(
|
||||
path: '/masters/warehouses',
|
||||
label: '입고지 관리',
|
||||
icon: LucideIcons.warehouse,
|
||||
icon: lucide.LucideIcons.warehouse,
|
||||
summary: '창고 주소와 사용여부를 설정합니다.',
|
||||
),
|
||||
AppPageDescriptor(
|
||||
path: '/masters/customers',
|
||||
label: '회사 관리',
|
||||
icon: LucideIcons.building,
|
||||
icon: lucide.LucideIcons.building,
|
||||
summary: '고객사 연락처와 주소 정보를 관리합니다.',
|
||||
),
|
||||
AppPageDescriptor(
|
||||
path: '/masters/users',
|
||||
label: '사용자 관리',
|
||||
icon: LucideIcons.users,
|
||||
icon: lucide.LucideIcons.users,
|
||||
summary: '사번, 그룹, 사용여부를 관리합니다.',
|
||||
),
|
||||
AppPageDescriptor(
|
||||
path: '/masters/groups',
|
||||
label: '그룹 관리',
|
||||
icon: LucideIcons.layers,
|
||||
icon: lucide.LucideIcons.layers,
|
||||
summary: '권한 그룹과 설명, 기본여부를 정의합니다.',
|
||||
),
|
||||
AppPageDescriptor(
|
||||
path: '/masters/menus',
|
||||
label: '메뉴 관리',
|
||||
icon: LucideIcons.listTree,
|
||||
icon: lucide.LucideIcons.listTree,
|
||||
summary: '메뉴 계층과 경로, 노출 순서를 구성합니다.',
|
||||
),
|
||||
AppPageDescriptor(
|
||||
path: '/masters/group-permissions',
|
||||
label: '그룹 메뉴 권한',
|
||||
icon: LucideIcons.shieldCheck,
|
||||
icon: lucide.LucideIcons.shieldCheck,
|
||||
summary: '그룹별 메뉴 CRUD 권한을 설정합니다.',
|
||||
),
|
||||
],
|
||||
@@ -119,25 +119,25 @@ const appSections = <AppSectionDescriptor>[
|
||||
AppPageDescriptor(
|
||||
path: '/approvals/requests',
|
||||
label: '결재 관리',
|
||||
icon: LucideIcons.fileCheck,
|
||||
icon: lucide.LucideIcons.fileCheck,
|
||||
summary: '결재 번호, 상태, 상신자를 관리합니다.',
|
||||
),
|
||||
AppPageDescriptor(
|
||||
path: '/approvals/steps',
|
||||
label: '결재 단계',
|
||||
icon: LucideIcons.workflow,
|
||||
icon: lucide.LucideIcons.workflow,
|
||||
summary: '단계 순서와 승인자 할당을 설정합니다.',
|
||||
),
|
||||
AppPageDescriptor(
|
||||
path: '/approvals/history',
|
||||
label: '결재 이력',
|
||||
icon: LucideIcons.history,
|
||||
icon: lucide.LucideIcons.history,
|
||||
summary: '결재 단계별 변경 이력을 조회합니다.',
|
||||
),
|
||||
AppPageDescriptor(
|
||||
path: '/approvals/templates',
|
||||
label: '결재 템플릿',
|
||||
icon: LucideIcons.fileSpreadsheet,
|
||||
icon: lucide.LucideIcons.fileSpreadsheet,
|
||||
summary: '반복되는 결재 흐름을 템플릿으로 관리합니다.',
|
||||
),
|
||||
],
|
||||
@@ -148,7 +148,7 @@ const appSections = <AppSectionDescriptor>[
|
||||
AppPageDescriptor(
|
||||
path: '/utilities/postal-search',
|
||||
label: '우편번호 검색',
|
||||
icon: LucideIcons.search,
|
||||
icon: lucide.LucideIcons.search,
|
||||
summary: '모달 기반 우편번호 검색 도구입니다.',
|
||||
),
|
||||
],
|
||||
@@ -159,7 +159,7 @@ const appSections = <AppSectionDescriptor>[
|
||||
AppPageDescriptor(
|
||||
path: '/reports',
|
||||
label: '보고서',
|
||||
icon: LucideIcons.fileDown,
|
||||
icon: lucide.LucideIcons.fileDown,
|
||||
summary: '조건 필터와 PDF/XLSX 다운로드 기능입니다.',
|
||||
),
|
||||
],
|
||||
|
||||
@@ -2,18 +2,23 @@
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api_error.dart';
|
||||
|
||||
/// 공통 API 클라이언트 (Dio 래퍼)
|
||||
/// - 모든 HTTP 호출은 이 클래스를 통해 이루어진다.
|
||||
/// - BaseURL/타임아웃/인증/로깅/에러 처리 등을 중앙집중화한다.
|
||||
class ApiClient {
|
||||
ApiClient({required Dio dio, ApiErrorMapper? errorMapper})
|
||||
: _dio = dio,
|
||||
_errorMapper = errorMapper ?? const ApiErrorMapper();
|
||||
|
||||
final Dio _dio;
|
||||
final ApiErrorMapper _errorMapper;
|
||||
|
||||
/// 내부에서 사용하는 Dio 인스턴스
|
||||
/// 외부에서 Dio 직접 사용을 최소화하고, 가능하면 아래 헬퍼 메서드를 사용한다.
|
||||
Dio get dio => _dio;
|
||||
|
||||
ApiClient({required Dio dio}) : _dio = dio;
|
||||
|
||||
/// GET 요청 헬퍼
|
||||
Future<Response<T>> get<T>(
|
||||
String path, {
|
||||
@@ -21,7 +26,14 @@ class ApiClient {
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) {
|
||||
return _dio.get<T>(path, queryParameters: query, options: options, cancelToken: cancelToken);
|
||||
return _wrap(
|
||||
() => _dio.get<T>(
|
||||
path,
|
||||
queryParameters: query,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// POST 요청 헬퍼
|
||||
@@ -32,7 +44,15 @@ class ApiClient {
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) {
|
||||
return _dio.post<T>(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken);
|
||||
return _wrap(
|
||||
() => _dio.post<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: query,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// PATCH 요청 헬퍼
|
||||
@@ -43,7 +63,15 @@ class ApiClient {
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) {
|
||||
return _dio.patch<T>(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken);
|
||||
return _wrap(
|
||||
() => _dio.patch<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: query,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// DELETE 요청 헬퍼
|
||||
@@ -54,7 +82,22 @@ class ApiClient {
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) {
|
||||
return _dio.delete<T>(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken);
|
||||
return _wrap(
|
||||
() => _dio.delete<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: query,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Response<T>> _wrap<T>(Future<Response<T>> Function() request) async {
|
||||
try {
|
||||
return await request();
|
||||
} on DioException catch (error) {
|
||||
throw _errorMapper.map(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
148
lib/core/network/api_error.dart
Normal file
148
lib/core/network/api_error.dart
Normal file
@@ -0,0 +1,148 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
enum ApiErrorCode {
|
||||
badRequest,
|
||||
unauthorized,
|
||||
notFound,
|
||||
conflict,
|
||||
unprocessableEntity,
|
||||
network,
|
||||
timeout,
|
||||
cancel,
|
||||
unknown,
|
||||
}
|
||||
|
||||
class ApiException implements Exception {
|
||||
const ApiException({
|
||||
required this.code,
|
||||
required this.message,
|
||||
this.statusCode,
|
||||
this.details,
|
||||
this.cause,
|
||||
});
|
||||
|
||||
final ApiErrorCode code;
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
final Map<String, dynamic>? details;
|
||||
final DioException? cause;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ApiException(code: $code, statusCode: $statusCode, message: $message)';
|
||||
}
|
||||
|
||||
class ApiErrorMapper {
|
||||
const ApiErrorMapper();
|
||||
|
||||
ApiException map(DioException error) {
|
||||
final status = error.response?.statusCode;
|
||||
final data = error.response?.data;
|
||||
final message = _resolveMessage(error, data);
|
||||
|
||||
if (error.type == DioExceptionType.connectionTimeout ||
|
||||
error.type == DioExceptionType.receiveTimeout ||
|
||||
error.type == DioExceptionType.sendTimeout) {
|
||||
return ApiException(
|
||||
code: ApiErrorCode.timeout,
|
||||
message: '서버 응답 시간이 초과되었습니다. 네트워크 상태를 확인하세요.',
|
||||
statusCode: status,
|
||||
cause: error,
|
||||
);
|
||||
}
|
||||
|
||||
if (error.type == DioExceptionType.connectionError ||
|
||||
error.type == DioExceptionType.badCertificate) {
|
||||
return ApiException(
|
||||
code: ApiErrorCode.network,
|
||||
message: '네트워크 연결에 실패했습니다. 잠시 후 다시 시도하세요.',
|
||||
statusCode: status,
|
||||
cause: error,
|
||||
);
|
||||
}
|
||||
|
||||
if (error.type == DioExceptionType.cancel) {
|
||||
return ApiException(
|
||||
code: ApiErrorCode.cancel,
|
||||
message: '요청이 취소되었습니다.',
|
||||
statusCode: status,
|
||||
cause: error,
|
||||
);
|
||||
}
|
||||
|
||||
if (status != null) {
|
||||
final details = _extractDetails(data);
|
||||
switch (status) {
|
||||
case 400:
|
||||
return ApiException(
|
||||
code: ApiErrorCode.badRequest,
|
||||
message: message,
|
||||
statusCode: status,
|
||||
details: details,
|
||||
cause: error,
|
||||
);
|
||||
case 401:
|
||||
return ApiException(
|
||||
code: ApiErrorCode.unauthorized,
|
||||
message: '세션이 만료되었습니다. 다시 로그인해 주세요.',
|
||||
statusCode: status,
|
||||
cause: error,
|
||||
);
|
||||
case 404:
|
||||
return ApiException(
|
||||
code: ApiErrorCode.notFound,
|
||||
message: '요청한 리소스를 찾을 수 없습니다.',
|
||||
statusCode: status,
|
||||
cause: error,
|
||||
);
|
||||
case 409:
|
||||
return ApiException(
|
||||
code: ApiErrorCode.conflict,
|
||||
message: message,
|
||||
statusCode: status,
|
||||
details: details,
|
||||
cause: error,
|
||||
);
|
||||
case 422:
|
||||
return ApiException(
|
||||
code: ApiErrorCode.unprocessableEntity,
|
||||
message: message,
|
||||
statusCode: status,
|
||||
details: details,
|
||||
cause: error,
|
||||
);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return ApiException(
|
||||
code: ApiErrorCode.unknown,
|
||||
message: message,
|
||||
statusCode: status,
|
||||
cause: error,
|
||||
);
|
||||
}
|
||||
|
||||
String _resolveMessage(DioException error, dynamic data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final message = data['message'] ?? data['error'];
|
||||
if (message is String && message.isNotEmpty) {
|
||||
return message;
|
||||
}
|
||||
} else if (data is String && data.isNotEmpty) {
|
||||
return data;
|
||||
}
|
||||
return error.message ?? '요청 처리 중 알 수 없는 오류가 발생했습니다.';
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _extractDetails(dynamic data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final errors = data['errors'];
|
||||
if (errors is Map<String, dynamic>) {
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,124 @@
|
||||
// ignore_for_file: public_member_api_docs
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// 인증 인터셉터(스켈레톤)
|
||||
import '../../services/token_storage.dart';
|
||||
|
||||
typedef RefreshTokenCallback = Future<TokenPair?> Function();
|
||||
|
||||
class TokenPair {
|
||||
const TokenPair({required this.accessToken, required this.refreshToken});
|
||||
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
}
|
||||
|
||||
/// 인증 인터셉터
|
||||
/// - 요청 전에 Authorization 헤더 주입
|
||||
/// - 401 수신 시 토큰 갱신 및 원요청 1회 재시도 (구현 예정)
|
||||
/// - 401 수신 시 토큰 갱신 및 원요청 1회 재시도
|
||||
class AuthInterceptor extends Interceptor {
|
||||
/// TODO: 토큰 저장/조회 서비스 주입 (예: AuthRepository)
|
||||
AuthInterceptor();
|
||||
AuthInterceptor({
|
||||
required TokenStorage tokenStorage,
|
||||
required Dio dio,
|
||||
this.onRefresh,
|
||||
}) : _tokenStorage = tokenStorage,
|
||||
_dio = dio;
|
||||
|
||||
final TokenStorage _tokenStorage;
|
||||
final Dio _dio;
|
||||
final RefreshTokenCallback? onRefresh;
|
||||
|
||||
final List<Completer<void>> _refreshQueue = [];
|
||||
bool _isRefreshing = false;
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
|
||||
// TODO: 저장된 토큰을 읽어 Authorization 헤더에 주입한다.
|
||||
// final token = await _authRepository.getToken();
|
||||
// if (token != null && token.isNotEmpty) {
|
||||
// options.headers['Authorization'] = 'Bearer $token';
|
||||
// }
|
||||
Future<void> onRequest(
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) async {
|
||||
final token = await _tokenStorage.readAccessToken();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
// TODO: 401 처리 로직(토큰 갱신 → 원요청 재시도) 구현
|
||||
// if (err.response?.statusCode == 401) { ... }
|
||||
Future<void> onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
if (!_shouldAttemptRefresh(err)) {
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _refreshToken();
|
||||
final response = await _retry(err.requestOptions);
|
||||
handler.resolve(response);
|
||||
} on _RefreshFailedException {
|
||||
await _tokenStorage.clear();
|
||||
handler.next(err);
|
||||
} on DioException catch (e) {
|
||||
handler.next(e);
|
||||
} catch (_) {
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
||||
|
||||
bool _shouldAttemptRefresh(DioException err) {
|
||||
return onRefresh != null &&
|
||||
err.response?.statusCode == 401 &&
|
||||
err.requestOptions.extra['__retry'] != true;
|
||||
}
|
||||
|
||||
Future<void> _refreshToken() async {
|
||||
if (_isRefreshing) {
|
||||
final completer = Completer<void>();
|
||||
_refreshQueue.add(completer);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
_isRefreshing = true;
|
||||
try {
|
||||
final callback = onRefresh;
|
||||
if (callback == null) {
|
||||
throw const _RefreshFailedException();
|
||||
}
|
||||
|
||||
final pair = await callback();
|
||||
if (pair == null) {
|
||||
throw const _RefreshFailedException();
|
||||
}
|
||||
|
||||
await _tokenStorage.writeAccessToken(pair.accessToken);
|
||||
await _tokenStorage.writeRefreshToken(pair.refreshToken);
|
||||
} finally {
|
||||
_isRefreshing = false;
|
||||
for (final completer in _refreshQueue) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
_refreshQueue.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response<dynamic>> _retry(RequestOptions requestOptions) async {
|
||||
final token = await _tokenStorage.readAccessToken();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
requestOptions.headers['Authorization'] = 'Bearer $token';
|
||||
} else {
|
||||
requestOptions.headers.remove('Authorization');
|
||||
}
|
||||
requestOptions.extra['__retry'] = true;
|
||||
return _dio.fetch(requestOptions);
|
||||
}
|
||||
}
|
||||
|
||||
class _RefreshFailedException implements Exception {
|
||||
const _RefreshFailedException();
|
||||
}
|
||||
|
||||
87
lib/core/permissions/permission_manager.dart
Normal file
87
lib/core/permissions/permission_manager.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../config/environment.dart';
|
||||
|
||||
enum PermissionAction { view, create, edit, delete, restore, approve }
|
||||
|
||||
class PermissionManager extends ChangeNotifier {
|
||||
PermissionManager({Map<String, Set<PermissionAction>>? overrides}) {
|
||||
if (overrides != null) {
|
||||
_overrides.addAll(overrides);
|
||||
}
|
||||
}
|
||||
|
||||
final Map<String, Set<PermissionAction>> _overrides = {};
|
||||
|
||||
bool can(String resource, PermissionAction action) {
|
||||
final override = _overrides[resource];
|
||||
if (override != null) {
|
||||
if (override.contains(PermissionAction.view) &&
|
||||
action == PermissionAction.view) {
|
||||
return true;
|
||||
}
|
||||
return override.contains(action);
|
||||
}
|
||||
return Environment.hasPermission(resource, action.name);
|
||||
}
|
||||
|
||||
void updateOverrides(Map<String, Set<PermissionAction>> overrides) {
|
||||
_overrides
|
||||
..clear()
|
||||
..addAll(overrides);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
class PermissionScope extends InheritedNotifier<PermissionManager> {
|
||||
const PermissionScope({
|
||||
super.key,
|
||||
required PermissionManager manager,
|
||||
required super.child,
|
||||
}) : super(notifier: manager);
|
||||
|
||||
static PermissionManager of(BuildContext context) {
|
||||
final scope = context.dependOnInheritedWidgetOfExactType<PermissionScope>();
|
||||
assert(
|
||||
scope != null,
|
||||
'PermissionScope.of() called with no PermissionScope ancestor.',
|
||||
);
|
||||
return scope!.notifier!;
|
||||
}
|
||||
}
|
||||
|
||||
class PermissionGate extends StatelessWidget {
|
||||
const PermissionGate({
|
||||
super.key,
|
||||
required this.resource,
|
||||
required this.action,
|
||||
required this.child,
|
||||
this.fallback,
|
||||
this.hide = true,
|
||||
});
|
||||
|
||||
final String resource;
|
||||
final PermissionAction action;
|
||||
final Widget child;
|
||||
final Widget? fallback;
|
||||
final bool hide;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final allowed = PermissionScope.of(context).can(resource, action);
|
||||
if (allowed) {
|
||||
return child;
|
||||
}
|
||||
if (hide) {
|
||||
return fallback ?? const SizedBox.shrink();
|
||||
}
|
||||
return IgnorePointer(
|
||||
ignoring: true,
|
||||
child: Opacity(opacity: 0.4, child: fallback ?? child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension PermissionActionKey on PermissionAction {
|
||||
String get key => name;
|
||||
}
|
||||
@@ -47,32 +47,32 @@ final appRouter = GoRouter(
|
||||
GoRoute(
|
||||
path: '/inventory/inbound',
|
||||
name: 'inventory-inbound',
|
||||
builder: (context, state) => const InboundPage(),
|
||||
builder: (context, state) => InboundPage(routeUri: state.uri),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/inventory/outbound',
|
||||
name: 'inventory-outbound',
|
||||
builder: (context, state) => const OutboundPage(),
|
||||
builder: (context, state) => OutboundPage(routeUri: state.uri),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/inventory/rental',
|
||||
name: 'inventory-rental',
|
||||
builder: (context, state) => const RentalPage(),
|
||||
builder: (context, state) => RentalPage(routeUri: state.uri),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/masters/vendors',
|
||||
name: 'masters-vendors',
|
||||
builder: (context, state) => const VendorPage(),
|
||||
builder: (context, state) => VendorPage(routeUri: state.uri),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/masters/products',
|
||||
name: 'masters-products',
|
||||
builder: (context, state) => const ProductPage(),
|
||||
builder: (context, state) => ProductPage(routeUri: state.uri),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/masters/warehouses',
|
||||
name: 'masters-warehouses',
|
||||
builder: (context, state) => const WarehousePage(),
|
||||
builder: (context, state) => WarehousePage(routeUri: state.uri),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/masters/customers',
|
||||
|
||||
19
lib/core/services/token_storage.dart
Normal file
19
lib/core/services/token_storage.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
import 'token_storage_stub.dart'
|
||||
if (dart.library.html) 'token_storage_web.dart'
|
||||
if (dart.library.io) 'token_storage_native.dart';
|
||||
|
||||
/// 액세스/리프레시 토큰을 안전하게 보관하는 스토리지 인터페이스.
|
||||
abstract class TokenStorage {
|
||||
Future<void> writeAccessToken(String? token);
|
||||
|
||||
Future<String?> readAccessToken();
|
||||
|
||||
Future<void> writeRefreshToken(String? token);
|
||||
|
||||
Future<String?> readRefreshToken();
|
||||
|
||||
Future<void> clear();
|
||||
}
|
||||
|
||||
/// 플랫폼에 맞는 스토리지 구현체를 생성한다.
|
||||
TokenStorage createTokenStorage() => buildTokenStorage();
|
||||
53
lib/core/services/token_storage_native.dart
Normal file
53
lib/core/services/token_storage_native.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import 'token_storage.dart';
|
||||
|
||||
const _kAccessTokenKey = 'access_token';
|
||||
const _kRefreshTokenKey = 'refresh_token';
|
||||
|
||||
TokenStorage buildTokenStorage() {
|
||||
const storage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
mOptions: MacOsOptions(),
|
||||
wOptions: WindowsOptions(),
|
||||
lOptions: LinuxOptions(),
|
||||
);
|
||||
return _SecureTokenStorage(storage);
|
||||
}
|
||||
|
||||
class _SecureTokenStorage implements TokenStorage {
|
||||
const _SecureTokenStorage(this._storage);
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
await _storage.delete(key: _kAccessTokenKey);
|
||||
await _storage.delete(key: _kRefreshTokenKey);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> readAccessToken() => _storage.read(key: _kAccessTokenKey);
|
||||
|
||||
@override
|
||||
Future<String?> readRefreshToken() => _storage.read(key: _kRefreshTokenKey);
|
||||
|
||||
@override
|
||||
Future<void> writeAccessToken(String? token) async {
|
||||
if (token == null || token.isEmpty) {
|
||||
await _storage.delete(key: _kAccessTokenKey);
|
||||
return;
|
||||
}
|
||||
await _storage.write(key: _kAccessTokenKey, value: token);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> writeRefreshToken(String? token) async {
|
||||
if (token == null || token.isEmpty) {
|
||||
await _storage.delete(key: _kRefreshTokenKey);
|
||||
return;
|
||||
}
|
||||
await _storage.write(key: _kRefreshTokenKey, value: token);
|
||||
}
|
||||
}
|
||||
24
lib/core/services/token_storage_stub.dart
Normal file
24
lib/core/services/token_storage_stub.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'token_storage.dart';
|
||||
|
||||
TokenStorage buildTokenStorage() => _UnsupportedTokenStorage();
|
||||
|
||||
class _UnsupportedTokenStorage implements TokenStorage {
|
||||
Never _unsupported() {
|
||||
throw UnsupportedError('TokenStorage is not supported on this platform.');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() async => _unsupported();
|
||||
|
||||
@override
|
||||
Future<String?> readAccessToken() async => _unsupported();
|
||||
|
||||
@override
|
||||
Future<String?> readRefreshToken() async => _unsupported();
|
||||
|
||||
@override
|
||||
Future<void> writeAccessToken(String? token) async => _unsupported();
|
||||
|
||||
@override
|
||||
Future<void> writeRefreshToken(String? token) async => _unsupported();
|
||||
}
|
||||
45
lib/core/services/token_storage_web.dart
Normal file
45
lib/core/services/token_storage_web.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
// ignore: deprecated_member_use, avoid_web_libraries_in_flutter
|
||||
import 'dart:html' as html;
|
||||
|
||||
import 'token_storage.dart';
|
||||
|
||||
const _kAccessTokenKey = 'access_token';
|
||||
const _kRefreshTokenKey = 'refresh_token';
|
||||
|
||||
TokenStorage buildTokenStorage() => _WebTokenStorage(html.window.localStorage);
|
||||
|
||||
class _WebTokenStorage implements TokenStorage {
|
||||
const _WebTokenStorage(this._storage);
|
||||
|
||||
final Map<String, String> _storage;
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
_storage.remove(_kAccessTokenKey);
|
||||
_storage.remove(_kRefreshTokenKey);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> readAccessToken() async => _storage[_kAccessTokenKey];
|
||||
|
||||
@override
|
||||
Future<String?> readRefreshToken() async => _storage[_kRefreshTokenKey];
|
||||
|
||||
@override
|
||||
Future<void> writeAccessToken(String? token) async {
|
||||
if (token == null || token.isEmpty) {
|
||||
_storage.remove(_kAccessTokenKey);
|
||||
} else {
|
||||
_storage[_kAccessTokenKey] = token;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> writeRefreshToken(String? token) async {
|
||||
if (token == null || token.isEmpty) {
|
||||
_storage.remove(_kRefreshTokenKey);
|
||||
} else {
|
||||
_storage[_kRefreshTokenKey] = token;
|
||||
}
|
||||
}
|
||||
}
|
||||
208
lib/core/theme/superport_shad_theme.dart
Normal file
208
lib/core/theme/superport_shad_theme.dart
Normal file
@@ -0,0 +1,208 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
/// Superport UI에서 공통으로 사용하는 Shad 테마 정의.
|
||||
class SuperportShadTheme {
|
||||
const SuperportShadTheme._();
|
||||
|
||||
static const Color primaryColor = Color(0xFF1B4F87);
|
||||
static const Color successColor = Color(0xFF2E8B57);
|
||||
static const Color warningColor = Color(0xFFFFC107);
|
||||
static const Color dangerColor = Color(0xFFDC3545);
|
||||
static const Color infoColor = Color(0xFF17A2B8);
|
||||
|
||||
/// 라이트 모드용 Shad 테마를 반환한다.
|
||||
static ShadThemeData light() {
|
||||
return ShadThemeData(
|
||||
brightness: Brightness.light,
|
||||
colorScheme: ShadColorScheme(
|
||||
background: Color(0xFFFFFFFF),
|
||||
foreground: Color(0xFF09090B),
|
||||
card: Color(0xFFFFFFFF),
|
||||
cardForeground: Color(0xFF09090B),
|
||||
popover: Color(0xFFFFFFFF),
|
||||
popoverForeground: Color(0xFF09090B),
|
||||
primary: primaryColor,
|
||||
primaryForeground: Color(0xFFFAFAFA),
|
||||
secondary: Color(0xFFF4F4F5),
|
||||
secondaryForeground: Color(0xFF18181B),
|
||||
muted: Color(0xFFF4F4F5),
|
||||
mutedForeground: Color(0xFF71717A),
|
||||
accent: Color(0xFFF4F4F5),
|
||||
accentForeground: Color(0xFF18181B),
|
||||
destructive: Color(0xFFEF4444),
|
||||
destructiveForeground: Color(0xFFFAFAFA),
|
||||
border: Color(0xFFE4E4E7),
|
||||
input: Color(0xFFE4E4E7),
|
||||
ring: Color(0xFF18181B),
|
||||
selection: primaryColor,
|
||||
),
|
||||
textTheme: ShadTextTheme(
|
||||
h1: TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.3,
|
||||
),
|
||||
h2: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.3,
|
||||
),
|
||||
h3: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.3,
|
||||
),
|
||||
h4: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.3,
|
||||
),
|
||||
p: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: -0.2,
|
||||
height: 1.6,
|
||||
),
|
||||
blockquote: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
table: TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
|
||||
list: TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
|
||||
lead: TextStyle(fontSize: 20, fontWeight: FontWeight.w400),
|
||||
large: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
small: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||
muted: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: -0.2,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
radius: const BorderRadius.all(Radius.circular(8)),
|
||||
);
|
||||
}
|
||||
|
||||
/// 다크 모드용 Shad 테마를 반환한다.
|
||||
static ShadThemeData dark() {
|
||||
return ShadThemeData(
|
||||
brightness: Brightness.dark,
|
||||
colorScheme: ShadColorScheme(
|
||||
background: Color(0xFF09090B),
|
||||
foreground: Color(0xFFFAFAFA),
|
||||
card: Color(0xFF09090B),
|
||||
cardForeground: Color(0xFFFAFAFA),
|
||||
popover: Color(0xFF09090B),
|
||||
popoverForeground: Color(0xFFFAFAFA),
|
||||
primary: primaryColor,
|
||||
primaryForeground: Color(0xFFFAFAFA),
|
||||
secondary: Color(0xFF27272A),
|
||||
secondaryForeground: Color(0xFFFAFAFA),
|
||||
muted: Color(0xFF27272A),
|
||||
mutedForeground: Color(0xFFA1A1AA),
|
||||
accent: Color(0xFF27272A),
|
||||
accentForeground: Color(0xFFFAFAFA),
|
||||
destructive: Color(0xFF7F1D1D),
|
||||
destructiveForeground: Color(0xFFFAFAFA),
|
||||
border: Color(0xFF27272A),
|
||||
input: Color(0xFF27272A),
|
||||
ring: Color(0xFFD4D4D8),
|
||||
selection: primaryColor,
|
||||
),
|
||||
textTheme: ShadTextTheme(
|
||||
h1: TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.3,
|
||||
),
|
||||
h2: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.3,
|
||||
),
|
||||
h3: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.3,
|
||||
),
|
||||
h4: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.3,
|
||||
),
|
||||
p: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: -0.2,
|
||||
height: 1.6,
|
||||
),
|
||||
blockquote: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
table: TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
|
||||
list: TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
|
||||
lead: TextStyle(fontSize: 20, fontWeight: FontWeight.w400),
|
||||
large: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
small: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||
muted: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: -0.2,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
radius: const BorderRadius.all(Radius.circular(8)),
|
||||
);
|
||||
}
|
||||
|
||||
/// 상태 텍스트 배경을 위한 데코레이션을 반환한다.
|
||||
static BoxDecoration statusDecoration(String status) {
|
||||
Color backgroundColor;
|
||||
Color borderColor;
|
||||
|
||||
switch (status.toLowerCase()) {
|
||||
case 'active':
|
||||
case 'success':
|
||||
backgroundColor = successColor.withValues(alpha: 0.1);
|
||||
borderColor = successColor;
|
||||
break;
|
||||
case 'warning':
|
||||
case 'pending':
|
||||
backgroundColor = warningColor.withValues(alpha: 0.1);
|
||||
borderColor = warningColor;
|
||||
break;
|
||||
case 'danger':
|
||||
case 'error':
|
||||
backgroundColor = dangerColor.withValues(alpha: 0.1);
|
||||
borderColor = dangerColor;
|
||||
break;
|
||||
case 'info':
|
||||
backgroundColor = infoColor.withValues(alpha: 0.1);
|
||||
borderColor = infoColor;
|
||||
break;
|
||||
case 'inactive':
|
||||
case 'disabled':
|
||||
default:
|
||||
backgroundColor = Colors.grey.withValues(alpha: 0.1);
|
||||
borderColor = Colors.grey;
|
||||
}
|
||||
|
||||
return BoxDecoration(
|
||||
color: backgroundColor,
|
||||
border: Border.all(color: borderColor, width: 1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
);
|
||||
}
|
||||
}
|
||||
47
lib/core/theme/theme_controller.dart
Normal file
47
lib/core/theme/theme_controller.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 전역 테마 모드를 관리하는 컨트롤러.
|
||||
class ThemeController extends ChangeNotifier {
|
||||
ThemeController({ThemeMode initialMode = ThemeMode.system})
|
||||
: _mode = initialMode;
|
||||
|
||||
ThemeMode _mode;
|
||||
|
||||
ThemeMode get mode => _mode;
|
||||
|
||||
void update(ThemeMode mode) {
|
||||
if (_mode == mode) return;
|
||||
_mode = mode;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void cycle() {
|
||||
switch (_mode) {
|
||||
case ThemeMode.system:
|
||||
update(ThemeMode.light);
|
||||
break;
|
||||
case ThemeMode.light:
|
||||
update(ThemeMode.dark);
|
||||
break;
|
||||
case ThemeMode.dark:
|
||||
update(ThemeMode.system);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [ThemeController]를 하위 위젯에 제공하는 Inherited 위젯.
|
||||
class ThemeControllerScope extends InheritedNotifier<ThemeController> {
|
||||
const ThemeControllerScope({
|
||||
super.key,
|
||||
required ThemeController controller,
|
||||
required super.child,
|
||||
}) : super(notifier: controller);
|
||||
|
||||
static ThemeController of(BuildContext context) {
|
||||
final scope = context
|
||||
.dependOnInheritedWidgetOfExactType<ThemeControllerScope>();
|
||||
assert(scope != null, 'ThemeControllerScope가 위젯 트리에 없습니다.');
|
||||
return scope!.notifier!;
|
||||
}
|
||||
}
|
||||
@@ -211,19 +211,13 @@ class ApprovalStepActionInput {
|
||||
|
||||
/// 결재 단계를 일괄 등록/재배치하기 위한 입력 모델
|
||||
class ApprovalStepAssignmentInput {
|
||||
ApprovalStepAssignmentInput({
|
||||
required this.approvalId,
|
||||
required this.steps,
|
||||
});
|
||||
ApprovalStepAssignmentInput({required this.approvalId, required this.steps});
|
||||
|
||||
final int approvalId;
|
||||
final List<ApprovalStepAssignmentItem> steps;
|
||||
|
||||
Map<String, dynamic> toPayload() {
|
||||
return {
|
||||
'id': approvalId,
|
||||
'steps': steps.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
return {'id': approvalId, 'steps': steps.map((e) => e.toJson()).toList()};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
@@ -7,6 +8,8 @@ import '../../../../../core/config/environment.dart';
|
||||
import '../../../../../core/constants/app_sections.dart';
|
||||
import '../../../../../widgets/app_layout.dart';
|
||||
import '../../../../../widgets/components/filter_bar.dart';
|
||||
import '../../../../../widgets/components/superport_date_picker.dart';
|
||||
import '../../../../../widgets/components/superport_table.dart';
|
||||
import '../../../../../widgets/spec_page.dart';
|
||||
import '../../domain/entities/approval_history_record.dart';
|
||||
import '../../domain/repositories/approval_history_repository.dart';
|
||||
@@ -145,6 +148,19 @@ class _ApprovalHistoryEnabledPageState
|
||||
),
|
||||
],
|
||||
toolbar: FilterBar(
|
||||
actions: [
|
||||
ShadButton.outline(
|
||||
onPressed: _controller.isLoading ? null : _applyFilters,
|
||||
child: const Text('검색 적용'),
|
||||
),
|
||||
ShadButton.ghost(
|
||||
onPressed:
|
||||
_controller.isLoading || !_controller.hasActiveFilters
|
||||
? null
|
||||
: _resetFilters,
|
||||
child: const Text('필터 초기화'),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 240,
|
||||
@@ -180,21 +196,24 @@ class _ApprovalHistoryEnabledPageState
|
||||
),
|
||||
SizedBox(
|
||||
width: 220,
|
||||
child: ShadButton.outline(
|
||||
onPressed: _pickDateRange,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(lucide.LucideIcons.calendar, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_dateRange == null
|
||||
? '기간 선택'
|
||||
: '${_formatDate(_dateRange!.start)} ~ ${_formatDate(_dateRange!.end)}',
|
||||
),
|
||||
],
|
||||
child: SuperportDateRangePickerButton(
|
||||
value: _dateRange,
|
||||
dateFormat: intl.DateFormat('yyyy-MM-dd'),
|
||||
enabled: !_controller.isLoading,
|
||||
firstDate: DateTime(DateTime.now().year - 5),
|
||||
lastDate: DateTime(DateTime.now().year + 1),
|
||||
initialDateRange:
|
||||
_dateRange ??
|
||||
DateTimeRange(
|
||||
start: DateTime.now().subtract(const Duration(days: 7)),
|
||||
end: DateTime.now(),
|
||||
),
|
||||
onChanged: (range) {
|
||||
if (range == null) return;
|
||||
setState(() => _dateRange = range);
|
||||
_controller.updateDateRange(range.start, range.end);
|
||||
_controller.fetch(page: 1);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_dateRange != null)
|
||||
@@ -202,17 +221,6 @@ class _ApprovalHistoryEnabledPageState
|
||||
onPressed: _controller.isLoading ? null : _clearDateRange,
|
||||
child: const Text('기간 초기화'),
|
||||
),
|
||||
ShadButton.outline(
|
||||
onPressed: _controller.isLoading ? null : _applyFilters,
|
||||
child: const Text('검색 적용'),
|
||||
),
|
||||
ShadButton.ghost(
|
||||
onPressed:
|
||||
_controller.isLoading || !_controller.hasActiveFilters
|
||||
? null
|
||||
: _resetFilters,
|
||||
child: const Text('필터 초기화'),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ShadCard(
|
||||
@@ -283,27 +291,6 @@ class _ApprovalHistoryEnabledPageState
|
||||
_controller.fetch(page: 1);
|
||||
}
|
||||
|
||||
Future<void> _pickDateRange() async {
|
||||
final now = DateTime.now();
|
||||
final initial =
|
||||
_dateRange ??
|
||||
DateTimeRange(
|
||||
start: DateTime(now.year, now.month, now.day - 7),
|
||||
end: now,
|
||||
);
|
||||
final range = await showDateRangePicker(
|
||||
context: context,
|
||||
initialDateRange: initial,
|
||||
firstDate: DateTime(now.year - 5),
|
||||
lastDate: DateTime(now.year + 1),
|
||||
);
|
||||
if (range != null) {
|
||||
setState(() => _dateRange = range);
|
||||
_controller.updateDateRange(range.start, range.end);
|
||||
_controller.fetch(page: 1);
|
||||
}
|
||||
}
|
||||
|
||||
void _clearDateRange() {
|
||||
setState(() => _dateRange = null);
|
||||
_controller.updateDateRange(null, null);
|
||||
@@ -318,10 +305,6 @@ class _ApprovalHistoryEnabledPageState
|
||||
_controller.fetch(page: 1);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
return DateFormat('yyyy-MM-dd').format(date.toLocal());
|
||||
}
|
||||
|
||||
String _actionLabel(ApprovalHistoryActionFilter filter) {
|
||||
switch (filter) {
|
||||
case ApprovalHistoryActionFilter.all:
|
||||
@@ -349,58 +332,60 @@ class _ApprovalHistoryTable extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final normalizedQuery = query.trim().toLowerCase();
|
||||
final header = [
|
||||
'ID',
|
||||
'결재번호',
|
||||
'단계순서',
|
||||
'승인자',
|
||||
'행위',
|
||||
'변경전 상태',
|
||||
'변경후 상태',
|
||||
'작업일시',
|
||||
'비고',
|
||||
].map((label) => ShadTableCell.header(child: Text(label))).toList();
|
||||
|
||||
final columns = const [
|
||||
Text('ID'),
|
||||
Text('결재번호'),
|
||||
Text('단계순서'),
|
||||
Text('승인자'),
|
||||
Text('행위'),
|
||||
Text('변경전 상태'),
|
||||
Text('변경후 상태'),
|
||||
Text('작업일시'),
|
||||
Text('비고'),
|
||||
];
|
||||
|
||||
final rows = histories.map((history) {
|
||||
final isHighlighted =
|
||||
normalizedQuery.isNotEmpty &&
|
||||
history.approvalNo.toLowerCase().contains(normalizedQuery);
|
||||
return [
|
||||
ShadTableCell(child: Text(history.id.toString())),
|
||||
ShadTableCell(
|
||||
child: Text(
|
||||
history.approvalNo,
|
||||
style: isHighlighted
|
||||
? ShadTheme.of(
|
||||
context,
|
||||
).textTheme.small.copyWith(fontWeight: FontWeight.w600)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
ShadTableCell(
|
||||
child: Text(
|
||||
history.stepOrder == null ? '-' : history.stepOrder.toString(),
|
||||
),
|
||||
),
|
||||
ShadTableCell(child: Text(history.approver.name)),
|
||||
ShadTableCell(child: Text(history.action.name)),
|
||||
ShadTableCell(child: Text(history.fromStatus?.name ?? '-')),
|
||||
ShadTableCell(child: Text(history.toStatus.name)),
|
||||
ShadTableCell(
|
||||
child: Text(dateFormat.format(history.actionAt.toLocal())),
|
||||
),
|
||||
ShadTableCell(
|
||||
child: Text(
|
||||
history.note?.trim().isEmpty ?? true ? '-' : history.note!,
|
||||
),
|
||||
final highlightStyle = theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.foreground,
|
||||
);
|
||||
final noteText = history.note?.trim();
|
||||
final noteContent = noteText?.isNotEmpty == true ? noteText : null;
|
||||
final subLabelStyle = theme.textTheme.muted.copyWith(
|
||||
fontSize: (theme.textTheme.muted.fontSize ?? 14) - 1,
|
||||
);
|
||||
|
||||
return <Widget>[
|
||||
Text(history.id.toString()),
|
||||
Text(history.approvalNo, style: isHighlighted ? highlightStyle : null),
|
||||
Text(history.stepOrder == null ? '-' : history.stepOrder.toString()),
|
||||
Text(history.approver.name),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(history.action.name),
|
||||
if (noteContent != null) Text(noteContent, style: subLabelStyle),
|
||||
],
|
||||
),
|
||||
Text(history.fromStatus?.name ?? '-'),
|
||||
Text(history.toStatus.name),
|
||||
Text(dateFormat.format(history.actionAt.toLocal())),
|
||||
Text(noteContent ?? '-'),
|
||||
];
|
||||
}).toList();
|
||||
|
||||
return ShadTable.list(
|
||||
header: header,
|
||||
children: rows,
|
||||
return SuperportTable(
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
rowHeight: 64,
|
||||
maxHeight: 520,
|
||||
columnSpanExtent: (index) {
|
||||
switch (index) {
|
||||
case 1:
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../../../../core/config/environment.dart';
|
||||
import '../../../../../core/constants/app_sections.dart';
|
||||
import '../../../../../widgets/app_layout.dart';
|
||||
import '../../../../../widgets/components/filter_bar.dart';
|
||||
import '../../../../../widgets/components/superport_dialog.dart';
|
||||
import '../../../../../widgets/spec_page.dart';
|
||||
import '../controllers/approval_step_controller.dart';
|
||||
import '../../domain/entities/approval_step_input.dart';
|
||||
@@ -528,42 +529,25 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
|
||||
if (!mounted) return;
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
if (detail == null) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
final step = detail.step;
|
||||
final theme = ShadTheme.of(dialogContext);
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.all(24),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ShadCard(
|
||||
title: Text('결재 단계 상세', style: theme.textTheme.h3),
|
||||
description: Text(
|
||||
'결재번호 ${detail.approvalNo}',
|
||||
style: theme.textTheme.muted,
|
||||
await SuperportDialog.show<void>(
|
||||
context: context,
|
||||
dialog: SuperportDialog(
|
||||
title: '결재 단계 상세',
|
||||
description: '결재번호 ${detail.approvalNo}',
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 18,
|
||||
),
|
||||
footer: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ShadButton.ghost(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_DetailRow(label: '단계 순서', value: '${step.stepOrder}'),
|
||||
_DetailRow(label: '승인자', value: step.approver.name),
|
||||
_DetailRow(label: '상태', value: step.status.name),
|
||||
_DetailRow(
|
||||
label: '배정일시',
|
||||
value: _formatDate(step.assignedAt),
|
||||
),
|
||||
_DetailRow(label: '배정일시', value: _formatDate(step.assignedAt)),
|
||||
_DetailRow(
|
||||
label: '결정일시',
|
||||
value: step.decidedAt == null
|
||||
@@ -571,16 +555,13 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
|
||||
: _formatDate(step.decidedAt!),
|
||||
),
|
||||
_DetailRow(label: '템플릿', value: detail.templateName ?? '-'),
|
||||
_DetailRow(
|
||||
label: '트랜잭션번호',
|
||||
value: detail.transactionNo ?? '-',
|
||||
),
|
||||
_DetailRow(label: '트랜잭션번호', value: detail.transactionNo ?? '-'),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'비고',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
style: ShadTheme.of(
|
||||
context,
|
||||
).textTheme.small.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ShadTextarea(
|
||||
@@ -592,9 +573,6 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -724,28 +702,20 @@ class _StepFormDialogState extends State<_StepFormDialog> {
|
||||
final theme = ShadTheme.of(context);
|
||||
final materialTheme = Theme.of(context);
|
||||
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.all(24),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ShadCard(
|
||||
title: Text(widget.title, style: theme.textTheme.h3),
|
||||
footer: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ShadButton.ghost(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ShadButton(
|
||||
return SuperportDialog(
|
||||
title: widget.title,
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
primaryAction: ShadButton(
|
||||
key: const ValueKey('step_form_submit'),
|
||||
onPressed: _handleSubmit,
|
||||
child: Text(widget.submitLabel),
|
||||
),
|
||||
],
|
||||
secondaryAction: ShadButton.ghost(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -821,7 +791,6 @@ class _StepFormDialogState extends State<_StepFormDialog> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../../../../core/config/environment.dart';
|
||||
import '../../../../../core/constants/app_sections.dart';
|
||||
import '../../../../../widgets/app_layout.dart';
|
||||
import '../../../../../widgets/components/filter_bar.dart';
|
||||
import '../../../../../widgets/components/superport_table.dart';
|
||||
import '../../../../../widgets/components/superport_dialog.dart';
|
||||
import '../../../../../widgets/spec_page.dart';
|
||||
import '../../../domain/entities/approval_template.dart';
|
||||
@@ -151,6 +152,18 @@ class _ApprovalTemplateEnabledPageState
|
||||
),
|
||||
],
|
||||
toolbar: FilterBar(
|
||||
actions: [
|
||||
ShadButton.outline(
|
||||
onPressed: _controller.isLoading ? null : _applyFilters,
|
||||
child: const Text('검색 적용'),
|
||||
),
|
||||
ShadButton.ghost(
|
||||
onPressed: !_controller.isLoading && showReset
|
||||
? _resetFilters
|
||||
: null,
|
||||
child: const Text('필터 초기화'),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 260,
|
||||
@@ -183,16 +196,6 @@ class _ApprovalTemplateEnabledPageState
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
ShadButton.outline(
|
||||
onPressed: _controller.isLoading ? null : _applyFilters,
|
||||
child: const Text('검색 적용'),
|
||||
),
|
||||
ShadButton.ghost(
|
||||
onPressed: !_controller.isLoading && showReset
|
||||
? _resetFilters
|
||||
: null,
|
||||
child: const Text('필터 초기화'),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ShadCard(
|
||||
@@ -213,15 +216,79 @@ class _ApprovalTemplateEnabledPageState
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 480,
|
||||
child: ShadTable.list(
|
||||
header:
|
||||
['ID', '템플릿코드', '템플릿명', '설명', '사용', '변경일시', '동작']
|
||||
.map(
|
||||
(e) => ShadTableCell.header(child: Text(e)),
|
||||
SuperportTable.fromCells(
|
||||
header: const [
|
||||
ShadTableCell.header(child: Text('ID')),
|
||||
ShadTableCell.header(child: Text('템플릿코드')),
|
||||
ShadTableCell.header(child: Text('템플릿명')),
|
||||
ShadTableCell.header(child: Text('설명')),
|
||||
ShadTableCell.header(child: Text('사용')),
|
||||
ShadTableCell.header(child: Text('변경일시')),
|
||||
ShadTableCell.header(child: Text('동작')),
|
||||
],
|
||||
rows: templates.map((template) {
|
||||
return [
|
||||
ShadTableCell(child: Text('${template.id}')),
|
||||
ShadTableCell(child: Text(template.code)),
|
||||
ShadTableCell(child: Text(template.name)),
|
||||
ShadTableCell(
|
||||
child: Text(
|
||||
template.description?.isNotEmpty == true
|
||||
? template.description!
|
||||
: '-',
|
||||
),
|
||||
),
|
||||
ShadTableCell(
|
||||
child: template.isActive
|
||||
? const ShadBadge(child: Text('사용'))
|
||||
: const ShadBadge.outline(child: Text('미사용')),
|
||||
),
|
||||
ShadTableCell(
|
||||
child: Text(
|
||||
template.updatedAt == null
|
||||
? '-'
|
||||
: _dateFormat.format(
|
||||
template.updatedAt!.toLocal(),
|
||||
),
|
||||
),
|
||||
),
|
||||
ShadTableCell(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
ShadButton.ghost(
|
||||
key: ValueKey(
|
||||
'template_edit_${template.id}',
|
||||
),
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isSubmitting
|
||||
? null
|
||||
: () => _openEditTemplate(template),
|
||||
child: const Text('수정'),
|
||||
),
|
||||
template.isActive
|
||||
? ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isSubmitting
|
||||
? null
|
||||
: () => _confirmDelete(template),
|
||||
child: const Text('삭제'),
|
||||
)
|
||||
.toList(),
|
||||
: ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isSubmitting
|
||||
? null
|
||||
: () => _confirmRestore(template),
|
||||
child: const Text('복구'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}).toList(),
|
||||
rowHeight: 56,
|
||||
maxHeight: 480,
|
||||
columnSpanExtent: (index) {
|
||||
switch (index) {
|
||||
case 2:
|
||||
@@ -238,72 +305,6 @@ class _ApprovalTemplateEnabledPageState
|
||||
return const FixedTableSpanExtent(140);
|
||||
}
|
||||
},
|
||||
children: templates.map((template) {
|
||||
return [
|
||||
ShadTableCell(child: Text('${template.id}')),
|
||||
ShadTableCell(child: Text(template.code)),
|
||||
ShadTableCell(child: Text(template.name)),
|
||||
ShadTableCell(
|
||||
child: Text(
|
||||
template.description?.isNotEmpty == true
|
||||
? template.description!
|
||||
: '-',
|
||||
),
|
||||
),
|
||||
ShadTableCell(
|
||||
child: template.isActive
|
||||
? const ShadBadge(child: Text('사용'))
|
||||
: const ShadBadge.outline(
|
||||
child: Text('미사용'),
|
||||
),
|
||||
),
|
||||
ShadTableCell(
|
||||
child: Text(
|
||||
template.updatedAt == null
|
||||
? '-'
|
||||
: _dateFormat.format(
|
||||
template.updatedAt!.toLocal(),
|
||||
),
|
||||
),
|
||||
),
|
||||
ShadTableCell(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ShadButton.ghost(
|
||||
key: ValueKey(
|
||||
'template_edit_${template.id}',
|
||||
),
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isSubmitting
|
||||
? null
|
||||
: () => _openEditTemplate(template),
|
||||
child: const Text('수정'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
template.isActive
|
||||
? ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isSubmitting
|
||||
? null
|
||||
: () =>
|
||||
_confirmDelete(template),
|
||||
child: const Text('삭제'),
|
||||
)
|
||||
: ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isSubmitting
|
||||
? null
|
||||
: () =>
|
||||
_confirmRestore(template),
|
||||
child: const Text('복구'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
@@ -382,26 +383,23 @@ class _ApprovalTemplateEnabledPageState
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(ApprovalTemplate template) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
final confirmed = await SuperportDialog.show<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('템플릿 삭제'),
|
||||
content: Text(
|
||||
dialog: SuperportDialog(
|
||||
title: '템플릿 삭제',
|
||||
description:
|
||||
'"${template.name}" 템플릿을 삭제하시겠습니까?\n삭제 시 템플릿은 미사용 상태로 전환됩니다.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
ShadButton.ghost(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
ShadButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('삭제'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
final ok = await _controller.delete(template.id);
|
||||
@@ -412,24 +410,22 @@ class _ApprovalTemplateEnabledPageState
|
||||
}
|
||||
|
||||
Future<void> _confirmRestore(ApprovalTemplate template) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
final confirmed = await SuperportDialog.show<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('템플릿 복구'),
|
||||
content: Text('"${template.name}" 템플릿을 복구하시겠습니까?'),
|
||||
dialog: SuperportDialog(
|
||||
title: '템플릿 복구',
|
||||
description: '"${template.name}" 템플릿을 복구하시겠습니까?',
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
ShadButton.ghost(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
ShadButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('복구'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
final restored = await _controller.restore(template.id);
|
||||
@@ -454,10 +450,74 @@ class _ApprovalTemplateEnabledPageState
|
||||
String? errorText;
|
||||
StateSetter? modalSetState;
|
||||
|
||||
Future<void> handleSubmit() async {
|
||||
if (isSaving) return;
|
||||
final codeValue = codeController.text.trim();
|
||||
final nameValue = nameController.text.trim();
|
||||
if (!isEdit && codeValue.isEmpty) {
|
||||
modalSetState?.call(() => errorText = '템플릿 코드를 입력하세요.');
|
||||
return;
|
||||
}
|
||||
if (nameValue.isEmpty) {
|
||||
modalSetState?.call(() => errorText = '템플릿명을 입력하세요.');
|
||||
return;
|
||||
}
|
||||
final validation = _validateSteps(steps);
|
||||
if (validation != null) {
|
||||
modalSetState?.call(() => errorText = validation);
|
||||
return;
|
||||
}
|
||||
modalSetState?.call(() => errorText = null);
|
||||
final stepInputs = steps
|
||||
.map(
|
||||
(field) => ApprovalTemplateStepInput(
|
||||
id: field.id,
|
||||
stepOrder: int.parse(field.orderController.text.trim()),
|
||||
approverId: int.parse(field.approverController.text.trim()),
|
||||
note: field.noteController.text.trim().isEmpty
|
||||
? null
|
||||
: field.noteController.text.trim(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
final input = ApprovalTemplateInput(
|
||||
code: isEdit ? existingTemplate?.code : codeValue,
|
||||
name: nameValue,
|
||||
description: descriptionController.text.trim().isEmpty
|
||||
? null
|
||||
: descriptionController.text.trim(),
|
||||
note: noteController.text.trim().isEmpty
|
||||
? null
|
||||
: noteController.text.trim(),
|
||||
isActive: statusNotifier.value,
|
||||
);
|
||||
if (isEdit && existingTemplate == null) {
|
||||
modalSetState?.call(() => errorText = '템플릿 정보를 불러오지 못했습니다.');
|
||||
modalSetState?.call(() => isSaving = false);
|
||||
return;
|
||||
}
|
||||
|
||||
modalSetState?.call(() => isSaving = true);
|
||||
|
||||
final success = isEdit && existingTemplate != null
|
||||
? await _controller.update(
|
||||
existingTemplate.id,
|
||||
input,
|
||||
stepInputs,
|
||||
)
|
||||
: await _controller.create(input, stepInputs);
|
||||
if (success != null && mounted) {
|
||||
Navigator.of(context).pop(true);
|
||||
} else {
|
||||
modalSetState?.call(() => isSaving = false);
|
||||
}
|
||||
}
|
||||
|
||||
final result = await showSuperportDialog<bool>(
|
||||
context: context,
|
||||
title: isEdit ? '템플릿 수정' : '템플릿 생성',
|
||||
barrierDismissible: !isSaving,
|
||||
onSubmit: handleSubmit,
|
||||
body: StatefulBuilder(
|
||||
builder: (dialogContext, setModalState) {
|
||||
modalSetState = setModalState;
|
||||
@@ -594,68 +654,7 @@ class _ApprovalTemplateEnabledPageState
|
||||
child: const Text('취소'),
|
||||
),
|
||||
ShadButton(
|
||||
onPressed: () async {
|
||||
if (isSaving) return;
|
||||
final codeValue = codeController.text.trim();
|
||||
final nameValue = nameController.text.trim();
|
||||
if (!isEdit && codeValue.isEmpty) {
|
||||
modalSetState?.call(() => errorText = '템플릿 코드를 입력하세요.');
|
||||
return;
|
||||
}
|
||||
if (nameValue.isEmpty) {
|
||||
modalSetState?.call(() => errorText = '템플릿명을 입력하세요.');
|
||||
return;
|
||||
}
|
||||
final validation = _validateSteps(steps);
|
||||
if (validation != null) {
|
||||
modalSetState?.call(() => errorText = validation);
|
||||
return;
|
||||
}
|
||||
modalSetState?.call(() => errorText = null);
|
||||
final stepInputs = steps
|
||||
.map(
|
||||
(field) => ApprovalTemplateStepInput(
|
||||
id: field.id,
|
||||
stepOrder: int.parse(field.orderController.text.trim()),
|
||||
approverId: int.parse(field.approverController.text.trim()),
|
||||
note: field.noteController.text.trim().isEmpty
|
||||
? null
|
||||
: field.noteController.text.trim(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
final input = ApprovalTemplateInput(
|
||||
code: isEdit ? existingTemplate?.code : codeValue,
|
||||
name: nameValue,
|
||||
description: descriptionController.text.trim().isEmpty
|
||||
? null
|
||||
: descriptionController.text.trim(),
|
||||
note: noteController.text.trim().isEmpty
|
||||
? null
|
||||
: noteController.text.trim(),
|
||||
isActive: statusNotifier.value,
|
||||
);
|
||||
if (isEdit && existingTemplate == null) {
|
||||
modalSetState?.call(() => errorText = '템플릿 정보를 불러오지 못했습니다.');
|
||||
modalSetState?.call(() => isSaving = false);
|
||||
return;
|
||||
}
|
||||
|
||||
modalSetState?.call(() => isSaving = true);
|
||||
|
||||
final success = isEdit && existingTemplate != null
|
||||
? await _controller.update(
|
||||
existingTemplate.id,
|
||||
input,
|
||||
stepInputs,
|
||||
)
|
||||
: await _controller.create(input, stepInputs);
|
||||
if (success != null && mounted) {
|
||||
Navigator.of(context).pop(true);
|
||||
} else {
|
||||
modalSetState?.call(() => isSaving = false);
|
||||
}
|
||||
},
|
||||
onPressed: handleSubmit,
|
||||
child: Text(isEdit ? '수정 완료' : '생성 완료'),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,24 +1,313 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../../../widgets/spec_page.dart';
|
||||
import 'package:superport_v2/widgets/app_layout.dart';
|
||||
import 'package:superport_v2/widgets/components/empty_state.dart';
|
||||
|
||||
class DashboardPage extends StatelessWidget {
|
||||
const DashboardPage({super.key});
|
||||
|
||||
static const _recentTransactions = [
|
||||
('IN-20240312-003', '2024-03-12', '입고', '승인완료', '김담당'),
|
||||
('OUT-20240311-005', '2024-03-11', '출고', '출고대기', '이물류'),
|
||||
('RENT-20240310-001', '2024-03-10', '대여', '대여중', '박대여'),
|
||||
('APP-20240309-004', '2024-03-09', '결재', '진행중', '최결재'),
|
||||
];
|
||||
|
||||
static const _pendingApprovals = [
|
||||
('APP-20240312-010', '설비 구매', '2/4 단계 진행 중'),
|
||||
('APP-20240311-004', '창고 정기 점검', '승인 대기'),
|
||||
('APP-20240309-002', '계약 연장', '반려 후 재상신'),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SpecPage(
|
||||
return AppLayout(
|
||||
title: '대시보드',
|
||||
summary: '오늘 입고/출고, 결재 대기, 최근 트랜잭션을 한 눈에 볼 수 있는 메인 화면 구성.',
|
||||
sections: [
|
||||
SpecSection(
|
||||
title: '주요 위젯',
|
||||
items: [
|
||||
'오늘 입고/출고 건수, 대기 결재 수 KPI 카드',
|
||||
'최근 트랜잭션 리스트: 번호 · 일자 · 유형 · 상태 · 작성자',
|
||||
'내 결재 요청/대기 건 알림 패널',
|
||||
subtitle: '입·출·대여 현황과 결재 대기를 한 눈에 확인합니다.',
|
||||
breadcrumbs: const [AppBreadcrumbItem(label: '대시보드')],
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(right: 12, bottom: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
children: const [
|
||||
_KpiCard(
|
||||
icon: lucide.LucideIcons.packagePlus,
|
||||
label: '오늘 입고',
|
||||
value: '12건',
|
||||
trend: '+3 vs 어제',
|
||||
),
|
||||
_KpiCard(
|
||||
icon: lucide.LucideIcons.packageMinus,
|
||||
label: '오늘 출고',
|
||||
value: '9건',
|
||||
trend: '-2 vs 어제',
|
||||
),
|
||||
_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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _KpiCard extends StatelessWidget {
|
||||
const _KpiCard({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.trend,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final String trend;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 220, maxWidth: 260),
|
||||
child: ShadCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 20, color: theme.colorScheme.primary),
|
||||
const SizedBox(height: 12),
|
||||
Text(label, style: theme.textTheme.small),
|
||||
const SizedBox(height: 6),
|
||||
Text(value, style: theme.textTheme.h3),
|
||||
const SizedBox(height: 8),
|
||||
Text(trend, style: theme.textTheme.muted),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecentTransactionsCard extends StatelessWidget {
|
||||
const _RecentTransactionsCard({required this.transactions});
|
||||
|
||||
final List<(String, String, String, String, String)> transactions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return ShadCard(
|
||||
title: Text('최근 트랜잭션', style: theme.textTheme.h3),
|
||||
description: Text(
|
||||
'최근 7일간의 입·출고 및 대여/결재 흐름입니다.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 320,
|
||||
child: ShadTable.list(
|
||||
header: const [
|
||||
ShadTableCell.header(child: Text('번호')),
|
||||
ShadTableCell.header(child: Text('일자')),
|
||||
ShadTableCell.header(child: Text('유형')),
|
||||
ShadTableCell.header(child: Text('상태')),
|
||||
ShadTableCell.header(child: Text('작성자')),
|
||||
],
|
||||
children: [
|
||||
for (final row in transactions)
|
||||
[
|
||||
ShadTableCell(child: Text(row.$1)),
|
||||
ShadTableCell(child: Text(row.$2)),
|
||||
ShadTableCell(child: Text(row.$3)),
|
||||
ShadTableCell(child: Text(row.$4)),
|
||||
ShadTableCell(child: Text(row.$5)),
|
||||
],
|
||||
],
|
||||
columnSpanExtent: (index) => const FixedTableSpanExtent(140),
|
||||
rowSpanExtent: (index) => const FixedTableSpanExtent(52),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingApprovalCard extends StatelessWidget {
|
||||
const _PendingApprovalCard({required this.approvals});
|
||||
|
||||
final List<(String, String, String)> approvals;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
if (approvals.isEmpty) {
|
||||
return ShadCard(
|
||||
title: Text('내 결재 대기', style: theme.textTheme.h3),
|
||||
description: Text(
|
||||
'현재 승인 대기 중인 결재 요청입니다.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
child: const SuperportEmptyState(
|
||||
title: '대기 중인 결재가 없습니다',
|
||||
description: '새로운 결재 요청이 등록되면 이곳에서 바로 확인할 수 있습니다.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ShadCard(
|
||||
title: Text('내 결재 대기', style: theme.textTheme.h3),
|
||||
description: Text('현재 승인 대기 중인 결재 요청입니다.', style: theme.textTheme.muted),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final approval in approvals)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
lucide.LucideIcons.bell,
|
||||
size: 18,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
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: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReminderPanel extends StatelessWidget {
|
||||
const _ReminderPanel();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return ShadCard(
|
||||
title: Text('주의/알림', style: theme.textTheme.h3),
|
||||
description: Text(
|
||||
'지연된 결재나 시스템 점검 일정을 확인하세요.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: const [
|
||||
_ReminderItem(
|
||||
icon: lucide.LucideIcons.clock,
|
||||
label: '결재 지연',
|
||||
message: '영업부 장비 구매 결재가 2일째 대기 중입니다.',
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
_ReminderItem(
|
||||
icon: lucide.LucideIcons.triangleAlert,
|
||||
label: '시스템 점검',
|
||||
message: '2024-03-15 22:00 ~ 23:00 서버 점검이 예정되어 있습니다.',
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
_ReminderItem(
|
||||
icon: lucide.LucideIcons.mail,
|
||||
label: '고객 문의',
|
||||
message: '3건의 신규 고객 문의가 접수되었습니다.',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReminderItem extends StatelessWidget {
|
||||
const _ReminderItem({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: theme.colorScheme.secondary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: theme.textTheme.small),
|
||||
const SizedBox(height: 4),
|
||||
Text(message, style: theme.textTheme.p),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
232
lib/features/inventory/shared/catalogs.dart
Normal file
232
lib/features/inventory/shared/catalogs.dart
Normal file
@@ -0,0 +1,232 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
/// 인벤토리 폼에서 공유하는 제품 카탈로그 항목.
|
||||
class InventoryProductCatalogItem {
|
||||
const InventoryProductCatalogItem({
|
||||
required this.code,
|
||||
required this.name,
|
||||
required this.manufacturer,
|
||||
required this.unit,
|
||||
});
|
||||
|
||||
final String code;
|
||||
final String name;
|
||||
final String manufacturer;
|
||||
final String unit;
|
||||
}
|
||||
|
||||
String _normalizeText(String value) {
|
||||
return value.toLowerCase().replaceAll(RegExp(r'[^a-z0-9가-힣]'), '');
|
||||
}
|
||||
|
||||
/// 제품 카탈로그 유틸리티.
|
||||
class InventoryProductCatalog {
|
||||
static final List<InventoryProductCatalogItem> items = List.unmodifiable([
|
||||
const InventoryProductCatalogItem(
|
||||
code: 'P-100',
|
||||
name: 'XR-5000',
|
||||
manufacturer: '슈퍼벤더',
|
||||
unit: 'EA',
|
||||
),
|
||||
const InventoryProductCatalogItem(
|
||||
code: 'P-101',
|
||||
name: 'XR-5001',
|
||||
manufacturer: '슈퍼벤더',
|
||||
unit: 'EA',
|
||||
),
|
||||
const InventoryProductCatalogItem(
|
||||
code: 'P-102',
|
||||
name: 'Eco-200',
|
||||
manufacturer: '그린텍',
|
||||
unit: 'EA',
|
||||
),
|
||||
const InventoryProductCatalogItem(
|
||||
code: 'P-201',
|
||||
name: 'Delta-One',
|
||||
manufacturer: '델타',
|
||||
unit: 'SET',
|
||||
),
|
||||
const InventoryProductCatalogItem(
|
||||
code: 'P-210',
|
||||
name: 'SmartGauge A1',
|
||||
manufacturer: '슈퍼벤더',
|
||||
unit: 'EA',
|
||||
),
|
||||
const InventoryProductCatalogItem(
|
||||
code: 'P-305',
|
||||
name: 'PowerPack Mini',
|
||||
manufacturer: '에이치솔루션',
|
||||
unit: 'EA',
|
||||
),
|
||||
const InventoryProductCatalogItem(
|
||||
code: 'P-320',
|
||||
name: 'Hydra-Flow 2',
|
||||
manufacturer: '블루하이드',
|
||||
unit: 'EA',
|
||||
),
|
||||
const InventoryProductCatalogItem(
|
||||
code: 'P-401',
|
||||
name: 'SolarEdge Pro',
|
||||
manufacturer: '그린텍',
|
||||
unit: 'EA',
|
||||
),
|
||||
const InventoryProductCatalogItem(
|
||||
code: 'P-430',
|
||||
name: 'Alpha-Kit 12',
|
||||
manufacturer: '테크솔루션',
|
||||
unit: 'SET',
|
||||
),
|
||||
const InventoryProductCatalogItem(
|
||||
code: 'P-501',
|
||||
name: 'LogiSense 5',
|
||||
manufacturer: '슈퍼벤더',
|
||||
unit: 'EA',
|
||||
),
|
||||
]);
|
||||
|
||||
static final Map<String, InventoryProductCatalogItem> _byKey = {
|
||||
for (final item in items) _normalizeText(item.name): item,
|
||||
};
|
||||
|
||||
static InventoryProductCatalogItem? match(String value) {
|
||||
if (value.isEmpty) return null;
|
||||
return _byKey[_normalizeText(value)];
|
||||
}
|
||||
|
||||
static List<InventoryProductCatalogItem> filter(String query) {
|
||||
final normalized = _normalizeText(query.trim());
|
||||
if (normalized.isEmpty) {
|
||||
return items.take(12).toList();
|
||||
}
|
||||
final lower = query.trim().toLowerCase();
|
||||
return [
|
||||
for (final item in items)
|
||||
if (_normalizeText(item.name).contains(normalized) ||
|
||||
item.code.toLowerCase().contains(lower))
|
||||
item,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// 고객 카탈로그 항목.
|
||||
class InventoryCustomerCatalogItem {
|
||||
const InventoryCustomerCatalogItem({
|
||||
required this.code,
|
||||
required this.name,
|
||||
required this.industry,
|
||||
required this.region,
|
||||
});
|
||||
|
||||
final String code;
|
||||
final String name;
|
||||
final String industry;
|
||||
final String region;
|
||||
}
|
||||
|
||||
/// 고객 카탈로그 유틸리티.
|
||||
class InventoryCustomerCatalog {
|
||||
static final List<InventoryCustomerCatalogItem> items = List.unmodifiable([
|
||||
const InventoryCustomerCatalogItem(
|
||||
code: 'C-1001',
|
||||
name: '슈퍼포트 파트너',
|
||||
industry: '물류',
|
||||
region: '서울',
|
||||
),
|
||||
const InventoryCustomerCatalogItem(
|
||||
code: 'C-1002',
|
||||
name: '그린에너지',
|
||||
industry: '에너지',
|
||||
region: '대전',
|
||||
),
|
||||
const InventoryCustomerCatalogItem(
|
||||
code: 'C-1003',
|
||||
name: '테크솔루션',
|
||||
industry: 'IT 서비스',
|
||||
region: '부산',
|
||||
),
|
||||
const InventoryCustomerCatalogItem(
|
||||
code: 'C-1004',
|
||||
name: '에이치솔루션',
|
||||
industry: '제조',
|
||||
region: '인천',
|
||||
),
|
||||
const InventoryCustomerCatalogItem(
|
||||
code: 'C-1005',
|
||||
name: '블루하이드',
|
||||
industry: '해양장비',
|
||||
region: '울산',
|
||||
),
|
||||
const InventoryCustomerCatalogItem(
|
||||
code: 'C-1010',
|
||||
name: '넥스트파워',
|
||||
industry: '발전설비',
|
||||
region: '광주',
|
||||
),
|
||||
const InventoryCustomerCatalogItem(
|
||||
code: 'C-1011',
|
||||
name: '씨에스테크',
|
||||
industry: '반도체',
|
||||
region: '수원',
|
||||
),
|
||||
const InventoryCustomerCatalogItem(
|
||||
code: 'C-1012',
|
||||
name: '알파시스템',
|
||||
industry: '장비임대',
|
||||
region: '대구',
|
||||
),
|
||||
const InventoryCustomerCatalogItem(
|
||||
code: 'C-1013',
|
||||
name: '스타트랩',
|
||||
industry: '연구개발',
|
||||
region: '세종',
|
||||
),
|
||||
const InventoryCustomerCatalogItem(
|
||||
code: 'C-1014',
|
||||
name: '메가스틸',
|
||||
industry: '철강',
|
||||
region: '포항',
|
||||
),
|
||||
]);
|
||||
|
||||
static final Map<String, InventoryCustomerCatalogItem> _byName = {
|
||||
for (final item in items) item.name: item,
|
||||
};
|
||||
|
||||
static InventoryCustomerCatalogItem? byName(String name) => _byName[name];
|
||||
|
||||
static List<InventoryCustomerCatalogItem> filter(String query) {
|
||||
final normalized = _normalizeText(query.trim());
|
||||
if (normalized.isEmpty) {
|
||||
return items;
|
||||
}
|
||||
final lower = query.trim().toLowerCase();
|
||||
return [
|
||||
for (final item in items)
|
||||
if (_normalizeText(item.name).contains(normalized) ||
|
||||
item.code.toLowerCase().contains(lower) ||
|
||||
_normalizeText(item.industry).contains(normalized) ||
|
||||
_normalizeText(item.region).contains(normalized))
|
||||
item,
|
||||
];
|
||||
}
|
||||
|
||||
static String displayLabel(String name) {
|
||||
final item = byName(name);
|
||||
if (item == null) {
|
||||
return name;
|
||||
}
|
||||
return '${item.name} (${item.code})';
|
||||
}
|
||||
}
|
||||
|
||||
/// 검색 결과가 없을 때 노출할 기본 위젯.
|
||||
Widget buildEmptySearchResult(
|
||||
ShadTextTheme textTheme, {
|
||||
String message = '검색 결과가 없습니다.',
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(message, style: textTheme.muted),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../catalogs.dart';
|
||||
|
||||
/// 제품명 입력 시 카탈로그 자동완성을 제공하는 필드.
|
||||
class InventoryProductAutocompleteField extends StatefulWidget {
|
||||
const InventoryProductAutocompleteField({
|
||||
super.key,
|
||||
required this.productController,
|
||||
required this.productFocusNode,
|
||||
required this.manufacturerController,
|
||||
required this.unitController,
|
||||
required this.onCatalogMatched,
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
final TextEditingController productController;
|
||||
final FocusNode productFocusNode;
|
||||
final TextEditingController manufacturerController;
|
||||
final TextEditingController unitController;
|
||||
final ValueChanged<InventoryProductCatalogItem?> onCatalogMatched;
|
||||
final VoidCallback? onChanged;
|
||||
|
||||
@override
|
||||
State<InventoryProductAutocompleteField> createState() =>
|
||||
_InventoryProductAutocompleteFieldState();
|
||||
}
|
||||
|
||||
class _InventoryProductAutocompleteFieldState
|
||||
extends State<InventoryProductAutocompleteField> {
|
||||
InventoryProductCatalogItem? _catalogMatch;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_catalogMatch = InventoryProductCatalog.match(
|
||||
widget.productController.text.trim(),
|
||||
);
|
||||
if (_catalogMatch != null) {
|
||||
_applyCatalog(_catalogMatch!, updateProduct: false);
|
||||
}
|
||||
widget.productController.addListener(_handleTextChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant InventoryProductAutocompleteField oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!identical(oldWidget.productController, widget.productController)) {
|
||||
oldWidget.productController.removeListener(_handleTextChanged);
|
||||
widget.productController.addListener(_handleTextChanged);
|
||||
_catalogMatch = InventoryProductCatalog.match(
|
||||
widget.productController.text.trim(),
|
||||
);
|
||||
if (_catalogMatch != null) {
|
||||
_applyCatalog(_catalogMatch!, updateProduct: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTextChanged() {
|
||||
final text = widget.productController.text.trim();
|
||||
final match = InventoryProductCatalog.match(text);
|
||||
if (match != null) {
|
||||
_applyCatalog(match);
|
||||
return;
|
||||
}
|
||||
if (_catalogMatch != null) {
|
||||
setState(() {
|
||||
_catalogMatch = null;
|
||||
});
|
||||
widget.onCatalogMatched(null);
|
||||
if (widget.manufacturerController.text.isNotEmpty) {
|
||||
widget.manufacturerController.clear();
|
||||
}
|
||||
if (widget.unitController.text.isNotEmpty) {
|
||||
widget.unitController.clear();
|
||||
}
|
||||
}
|
||||
widget.onChanged?.call();
|
||||
}
|
||||
|
||||
void _applyCatalog(
|
||||
InventoryProductCatalogItem match, {
|
||||
bool updateProduct = true,
|
||||
}) {
|
||||
setState(() {
|
||||
_catalogMatch = match;
|
||||
});
|
||||
widget.onCatalogMatched(match);
|
||||
if (updateProduct && widget.productController.text != match.name) {
|
||||
widget.productController.text = match.name;
|
||||
widget.productController.selection = TextSelection.collapsed(
|
||||
offset: widget.productController.text.length,
|
||||
);
|
||||
}
|
||||
if (widget.manufacturerController.text != match.manufacturer) {
|
||||
widget.manufacturerController.text = match.manufacturer;
|
||||
}
|
||||
if (widget.unitController.text != match.unit) {
|
||||
widget.unitController.text = match.unit;
|
||||
}
|
||||
widget.onChanged?.call();
|
||||
}
|
||||
|
||||
Iterable<InventoryProductCatalogItem> _options(String query) {
|
||||
return InventoryProductCatalog.filter(query);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.productController.removeListener(_handleTextChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return RawAutocomplete<InventoryProductCatalogItem>(
|
||||
textEditingController: widget.productController,
|
||||
focusNode: widget.productFocusNode,
|
||||
optionsBuilder: (textEditingValue) {
|
||||
return _options(textEditingValue.text);
|
||||
},
|
||||
displayStringForOption: (option) => option.name,
|
||||
onSelected: (option) {
|
||||
_applyCatalog(option);
|
||||
},
|
||||
fieldViewBuilder:
|
||||
(context, textEditingController, focusNode, onFieldSubmitted) {
|
||||
return ShadInput(
|
||||
controller: textEditingController,
|
||||
focusNode: focusNode,
|
||||
placeholder: const Text('제품명'),
|
||||
onChanged: (_) => widget.onChanged?.call(),
|
||||
onSubmitted: (_) => onFieldSubmitted(),
|
||||
);
|
||||
},
|
||||
optionsViewBuilder: (context, onSelected, options) {
|
||||
if (options.isEmpty) {
|
||||
return Align(
|
||||
alignment: AlignmentDirectional.topStart,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: 240,
|
||||
),
|
||||
child: Material(
|
||||
elevation: 6,
|
||||
color: theme.colorScheme.background,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: theme.colorScheme.border),
|
||||
),
|
||||
child: buildEmptySearchResult(theme.textTheme),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Align(
|
||||
alignment: AlignmentDirectional.topStart,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: 260,
|
||||
),
|
||||
child: Material(
|
||||
elevation: 6,
|
||||
color: theme.colorScheme.background,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: theme.colorScheme.border),
|
||||
),
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
itemCount: options.length,
|
||||
itemBuilder: (context, index) {
|
||||
final option = options.elementAt(index);
|
||||
return InkWell(
|
||||
onTap: () => onSelected(option),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(option.name, style: theme.textTheme.p),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${option.code} · ${option.manufacturer} · ${option.unit}',
|
||||
style: theme.textTheme.muted.copyWith(
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
import 'package:superport_v2/core/common/utils/json_utils.dart';
|
||||
|
||||
import '../../domain/entities/stock_transaction.dart';
|
||||
|
||||
/// 재고 트랜잭션 DTO
|
||||
///
|
||||
/// - API 응답(JSON)을 도메인 엔티티로 변환하고, 요청 페이로드를 구성한다.
|
||||
class StockTransactionDto {
|
||||
StockTransactionDto({
|
||||
this.id,
|
||||
required this.transactionNo,
|
||||
required this.transactionDate,
|
||||
required this.type,
|
||||
required this.status,
|
||||
required this.warehouse,
|
||||
required this.createdBy,
|
||||
this.note,
|
||||
this.isActive = true,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
this.lines = const [],
|
||||
this.customers = const [],
|
||||
this.approval,
|
||||
this.expectedReturnDate,
|
||||
});
|
||||
|
||||
final int? id;
|
||||
final String transactionNo;
|
||||
final DateTime transactionDate;
|
||||
final StockTransactionType type;
|
||||
final StockTransactionStatus status;
|
||||
final StockTransactionWarehouse warehouse;
|
||||
final StockTransactionEmployee createdBy;
|
||||
final String? note;
|
||||
final bool isActive;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
final List<StockTransactionLine> lines;
|
||||
final List<StockTransactionCustomer> customers;
|
||||
final StockTransactionApprovalSummary? approval;
|
||||
final DateTime? expectedReturnDate;
|
||||
|
||||
/// JSON 객체를 DTO로 변환한다.
|
||||
factory StockTransactionDto.fromJson(Map<String, dynamic> json) {
|
||||
final typeJson = json['transaction_type'] as Map<String, dynamic>?;
|
||||
final statusJson = json['transaction_status'] as Map<String, dynamic>?;
|
||||
final warehouseJson = json['warehouse'] as Map<String, dynamic>?;
|
||||
final createdByJson = json['created_by'] as Map<String, dynamic>?;
|
||||
|
||||
return StockTransactionDto(
|
||||
id: json['id'] as int?,
|
||||
transactionNo: json['transaction_no'] as String? ?? '',
|
||||
transactionDate: _parseDate(json['transaction_date']) ?? DateTime.now(),
|
||||
type: _parseType(typeJson),
|
||||
status: _parseStatus(statusJson),
|
||||
warehouse: _parseWarehouse(warehouseJson),
|
||||
createdBy: _parseEmployee(createdByJson),
|
||||
note: json['note'] as String?,
|
||||
isActive: (json['is_active'] as bool?) ?? true,
|
||||
createdAt: _parseDateTime(json['created_at']),
|
||||
updatedAt: _parseDateTime(json['updated_at']),
|
||||
lines: _parseLines(json),
|
||||
customers: _parseCustomers(json),
|
||||
approval: _parseApproval(json['approval']),
|
||||
expectedReturnDate:
|
||||
_parseDate(json['expected_return_date']) ??
|
||||
_parseDate(json['planned_return_date']) ??
|
||||
_parseDate(json['return_due_date']),
|
||||
);
|
||||
}
|
||||
|
||||
/// 도메인 엔티티로 변환한다.
|
||||
StockTransaction toEntity() {
|
||||
return StockTransaction(
|
||||
id: id,
|
||||
transactionNo: transactionNo,
|
||||
transactionDate: transactionDate,
|
||||
type: type,
|
||||
status: status,
|
||||
warehouse: warehouse,
|
||||
createdBy: createdBy,
|
||||
note: note,
|
||||
isActive: isActive,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
lines: lines,
|
||||
customers: customers,
|
||||
approval: approval,
|
||||
expectedReturnDate: expectedReturnDate,
|
||||
);
|
||||
}
|
||||
|
||||
/// 페이지네이션 응답을 파싱한다.
|
||||
static PaginatedResult<StockTransaction> parsePaginated(dynamic json) {
|
||||
final raw = JsonUtils.extractList(json, keys: const ['items']);
|
||||
final items = raw
|
||||
.map(StockTransactionDto.fromJson)
|
||||
.map((dto) => dto.toEntity())
|
||||
.toList(growable: false);
|
||||
final map = json is Map<String, dynamic> ? json : <String, dynamic>{};
|
||||
return PaginatedResult<StockTransaction>(
|
||||
items: items,
|
||||
page: JsonUtils.readInt(map, 'page', fallback: 1),
|
||||
pageSize: JsonUtils.readInt(map, 'page_size', fallback: items.length),
|
||||
total: JsonUtils.readInt(map, 'total', fallback: items.length),
|
||||
);
|
||||
}
|
||||
|
||||
/// 단건 응답을 파싱한다.
|
||||
static StockTransaction? parseSingle(dynamic json) {
|
||||
final map = JsonUtils.extractMap(json, keys: const ['data']);
|
||||
if (map.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return StockTransactionDto.fromJson(map).toEntity();
|
||||
}
|
||||
}
|
||||
|
||||
StockTransactionType _parseType(Map<String, dynamic>? json) {
|
||||
return StockTransactionType(
|
||||
id: json?['id'] as int? ?? 0,
|
||||
name: json?['type_name'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
StockTransactionStatus _parseStatus(Map<String, dynamic>? json) {
|
||||
return StockTransactionStatus(
|
||||
id: json?['id'] as int? ?? 0,
|
||||
name: json?['status_name'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
StockTransactionWarehouse _parseWarehouse(Map<String, dynamic>? json) {
|
||||
final zipcode = json?['zipcode'] as Map<String, dynamic>?;
|
||||
return StockTransactionWarehouse(
|
||||
id: json?['id'] as int? ?? 0,
|
||||
code: json?['warehouse_code'] as String? ?? '',
|
||||
name: json?['warehouse_name'] as String? ?? '',
|
||||
zipcode: zipcode?['zipcode'] as String?,
|
||||
addressLine: zipcode?['road_name'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
StockTransactionEmployee _parseEmployee(Map<String, dynamic>? json) {
|
||||
return StockTransactionEmployee(
|
||||
id: json?['id'] as int? ?? 0,
|
||||
employeeNo: json?['employee_no'] as String? ?? '',
|
||||
name: json?['employee_name'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
List<StockTransactionLine> _parseLines(Map<String, dynamic> json) {
|
||||
final raw = JsonUtils.extractList(json, keys: const ['lines']);
|
||||
return [
|
||||
for (final item in raw)
|
||||
StockTransactionLine(
|
||||
id: item['id'] as int?,
|
||||
lineNo: JsonUtils.readInt(item, 'line_no', fallback: 1),
|
||||
product: _parseProduct(item['product'] as Map<String, dynamic>?),
|
||||
quantity: JsonUtils.readInt(item, 'quantity', fallback: 0),
|
||||
unitPrice: _readDouble(item['unit_price']),
|
||||
note: item['note'] as String?,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
StockTransactionProduct _parseProduct(Map<String, dynamic>? json) {
|
||||
final vendorJson = json?['vendor'] as Map<String, dynamic>?;
|
||||
final uomJson = json?['uom'] as Map<String, dynamic>?;
|
||||
return StockTransactionProduct(
|
||||
id: json?['id'] as int? ?? 0,
|
||||
code: json?['product_code'] as String? ?? json?['code'] as String? ?? '',
|
||||
name: json?['product_name'] as String? ?? json?['name'] as String? ?? '',
|
||||
vendor: vendorJson == null
|
||||
? null
|
||||
: StockTransactionVendorSummary(
|
||||
id: vendorJson['id'] as int? ?? 0,
|
||||
name:
|
||||
vendorJson['vendor_name'] as String? ??
|
||||
vendorJson['name'] as String? ??
|
||||
'',
|
||||
),
|
||||
uom: uomJson == null
|
||||
? null
|
||||
: StockTransactionUomSummary(
|
||||
id: uomJson['id'] as int? ?? 0,
|
||||
name:
|
||||
uomJson['uom_name'] as String? ??
|
||||
uomJson['name'] as String? ??
|
||||
'',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<StockTransactionCustomer> _parseCustomers(Map<String, dynamic> json) {
|
||||
final raw = JsonUtils.extractList(json, keys: const ['customers']);
|
||||
return [
|
||||
for (final item in raw)
|
||||
StockTransactionCustomer(
|
||||
id: item['id'] as int?,
|
||||
customer: _parseCustomer(item['customer'] as Map<String, dynamic>?),
|
||||
note: item['note'] as String?,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
StockTransactionCustomerSummary _parseCustomer(Map<String, dynamic>? json) {
|
||||
return StockTransactionCustomerSummary(
|
||||
id: json?['id'] as int? ?? 0,
|
||||
code: json?['customer_code'] as String? ?? json?['code'] as String? ?? '',
|
||||
name: json?['customer_name'] as String? ?? json?['name'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
StockTransactionApprovalSummary? _parseApproval(dynamic raw) {
|
||||
if (raw is! Map<String, dynamic>) {
|
||||
return null;
|
||||
}
|
||||
final status = raw['approval_status'] as Map<String, dynamic>?;
|
||||
return StockTransactionApprovalSummary(
|
||||
id: raw['id'] as int? ?? 0,
|
||||
approvalNo: raw['approval_no'] as String? ?? '',
|
||||
status: status == null
|
||||
? null
|
||||
: StockTransactionApprovalStatusSummary(
|
||||
id: status['id'] as int? ?? 0,
|
||||
name:
|
||||
status['status_name'] as String? ??
|
||||
status['name'] as String? ??
|
||||
'',
|
||||
isBlocking: status['is_blocking_next'] as bool?,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
DateTime? _parseDate(Object? value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value is DateTime) {
|
||||
return value;
|
||||
}
|
||||
if (value is String && value.isNotEmpty) {
|
||||
return DateTime.tryParse(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
DateTime? _parseDateTime(Object? value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value is DateTime) {
|
||||
return value;
|
||||
}
|
||||
if (value is String && value.isNotEmpty) {
|
||||
return DateTime.tryParse(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
double _readDouble(Object? value) {
|
||||
if (value is double) {
|
||||
return value;
|
||||
}
|
||||
if (value is int) {
|
||||
return value.toDouble();
|
||||
}
|
||||
if (value is String) {
|
||||
return double.tryParse(value) ?? 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/// 재고 트랜잭션 도메인 엔티티
|
||||
///
|
||||
/// - 입고/출고/대여 공통으로 사용되는 헤더와 라인, 고객 연결 정보를 포함한다.
|
||||
class StockTransaction {
|
||||
StockTransaction({
|
||||
this.id,
|
||||
required this.transactionNo,
|
||||
required this.transactionDate,
|
||||
required this.type,
|
||||
required this.status,
|
||||
required this.warehouse,
|
||||
required this.createdBy,
|
||||
this.note,
|
||||
this.isActive = true,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
this.lines = const [],
|
||||
this.customers = const [],
|
||||
this.approval,
|
||||
this.expectedReturnDate,
|
||||
});
|
||||
|
||||
final int? id;
|
||||
final String transactionNo;
|
||||
final DateTime transactionDate;
|
||||
final StockTransactionType type;
|
||||
final StockTransactionStatus status;
|
||||
final StockTransactionWarehouse warehouse;
|
||||
final StockTransactionEmployee createdBy;
|
||||
final String? note;
|
||||
final bool isActive;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
final List<StockTransactionLine> lines;
|
||||
final List<StockTransactionCustomer> customers;
|
||||
final StockTransactionApprovalSummary? approval;
|
||||
final DateTime? expectedReturnDate;
|
||||
|
||||
int get itemCount => lines.length;
|
||||
|
||||
int get totalQuantity => lines.fold<int>(
|
||||
0,
|
||||
(previousValue, line) => previousValue + line.quantity,
|
||||
);
|
||||
|
||||
StockTransaction copyWith({
|
||||
int? id,
|
||||
String? transactionNo,
|
||||
DateTime? transactionDate,
|
||||
StockTransactionType? type,
|
||||
StockTransactionStatus? status,
|
||||
StockTransactionWarehouse? warehouse,
|
||||
StockTransactionEmployee? createdBy,
|
||||
String? note,
|
||||
bool? isActive,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
List<StockTransactionLine>? lines,
|
||||
List<StockTransactionCustomer>? customers,
|
||||
StockTransactionApprovalSummary? approval,
|
||||
DateTime? expectedReturnDate,
|
||||
}) {
|
||||
return StockTransaction(
|
||||
id: id ?? this.id,
|
||||
transactionNo: transactionNo ?? this.transactionNo,
|
||||
transactionDate: transactionDate ?? this.transactionDate,
|
||||
type: type ?? this.type,
|
||||
status: status ?? this.status,
|
||||
warehouse: warehouse ?? this.warehouse,
|
||||
createdBy: createdBy ?? this.createdBy,
|
||||
note: note ?? this.note,
|
||||
isActive: isActive ?? this.isActive,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
lines: lines ?? this.lines,
|
||||
customers: customers ?? this.customers,
|
||||
approval: approval ?? this.approval,
|
||||
expectedReturnDate: expectedReturnDate ?? this.expectedReturnDate,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 재고 트랜잭션 유형 요약 정보
|
||||
class StockTransactionType {
|
||||
StockTransactionType({required this.id, required this.name});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
}
|
||||
|
||||
/// 재고 트랜잭션 상태 요약 정보
|
||||
class StockTransactionStatus {
|
||||
StockTransactionStatus({required this.id, required this.name});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
}
|
||||
|
||||
/// 재고 트랜잭션 작성자 정보
|
||||
class StockTransactionEmployee {
|
||||
StockTransactionEmployee({
|
||||
required this.id,
|
||||
required this.employeeNo,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String employeeNo;
|
||||
final String name;
|
||||
}
|
||||
|
||||
/// 재고 트랜잭션 창고 정보 요약
|
||||
class StockTransactionWarehouse {
|
||||
StockTransactionWarehouse({
|
||||
required this.id,
|
||||
required this.code,
|
||||
required this.name,
|
||||
this.zipcode,
|
||||
this.addressLine,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String code;
|
||||
final String name;
|
||||
final String? zipcode;
|
||||
final String? addressLine;
|
||||
}
|
||||
|
||||
/// 재고 트랜잭션 품목(라인)
|
||||
class StockTransactionLine {
|
||||
StockTransactionLine({
|
||||
this.id,
|
||||
required this.lineNo,
|
||||
required this.product,
|
||||
required this.quantity,
|
||||
required this.unitPrice,
|
||||
this.note,
|
||||
});
|
||||
|
||||
final int? id;
|
||||
final int lineNo;
|
||||
final StockTransactionProduct product;
|
||||
final int quantity;
|
||||
final double unitPrice;
|
||||
final String? note;
|
||||
}
|
||||
|
||||
/// 재고 트랜잭션 품목의 제품 정보 요약
|
||||
class StockTransactionProduct {
|
||||
StockTransactionProduct({
|
||||
required this.id,
|
||||
required this.code,
|
||||
required this.name,
|
||||
this.vendor,
|
||||
this.uom,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String code;
|
||||
final String name;
|
||||
final StockTransactionVendorSummary? vendor;
|
||||
final StockTransactionUomSummary? uom;
|
||||
}
|
||||
|
||||
/// 재고 트랜잭션에 연결된 고객 정보
|
||||
class StockTransactionCustomer {
|
||||
StockTransactionCustomer({this.id, required this.customer, this.note});
|
||||
|
||||
final int? id;
|
||||
final StockTransactionCustomerSummary customer;
|
||||
final String? note;
|
||||
}
|
||||
|
||||
/// 고객 요약 정보
|
||||
class StockTransactionCustomerSummary {
|
||||
StockTransactionCustomerSummary({
|
||||
required this.id,
|
||||
required this.code,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String code;
|
||||
final String name;
|
||||
}
|
||||
|
||||
/// 제품의 공급사 요약 정보
|
||||
class StockTransactionVendorSummary {
|
||||
StockTransactionVendorSummary({required this.id, required this.name});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
}
|
||||
|
||||
/// 제품 단위 요약 정보
|
||||
class StockTransactionUomSummary {
|
||||
StockTransactionUomSummary({required this.id, required this.name});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
}
|
||||
|
||||
/// 결재 요약 정보
|
||||
class StockTransactionApprovalSummary {
|
||||
StockTransactionApprovalSummary({
|
||||
required this.id,
|
||||
required this.approvalNo,
|
||||
this.status,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String approvalNo;
|
||||
final StockTransactionApprovalStatusSummary? status;
|
||||
}
|
||||
|
||||
/// 결재 상태 요약 정보
|
||||
class StockTransactionApprovalStatusSummary {
|
||||
StockTransactionApprovalStatusSummary({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.isBlocking,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
final bool? isBlocking;
|
||||
}
|
||||
|
||||
extension StockTransactionLineX on List<StockTransactionLine> {
|
||||
/// 라인 품목 가격 총액을 계산한다.
|
||||
double get totalAmount =>
|
||||
fold<double>(0, (sum, line) => sum + (line.quantity * line.unitPrice));
|
||||
}
|
||||
@@ -15,6 +15,8 @@ class _LoginPageState extends State<LoginPage> {
|
||||
final idController = TextEditingController();
|
||||
final passwordController = TextEditingController();
|
||||
bool rememberMe = false;
|
||||
bool isLoading = false;
|
||||
String? errorMessage;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -23,7 +25,35 @@ class _LoginPageState extends State<LoginPage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleSubmit() {
|
||||
Future<void> _handleSubmit() async {
|
||||
if (isLoading) return;
|
||||
setState(() {
|
||||
errorMessage = null;
|
||||
isLoading = true;
|
||||
});
|
||||
|
||||
final id = idController.text.trim();
|
||||
final password = passwordController.text.trim();
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 600));
|
||||
|
||||
if (id.isEmpty || password.isEmpty) {
|
||||
setState(() {
|
||||
errorMessage = '아이디와 비밀번호를 모두 입력하세요.';
|
||||
isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setState(() {
|
||||
errorMessage = '비밀번호는 6자 이상이어야 합니다.';
|
||||
isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
context.go(dashboardRoutePath);
|
||||
}
|
||||
|
||||
@@ -73,9 +103,33 @@ class _LoginPageState extends State<LoginPage> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (errorMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
errorMessage!,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.destructive,
|
||||
),
|
||||
),
|
||||
),
|
||||
ShadButton(
|
||||
onPressed: _handleSubmit,
|
||||
child: const Text('로그인'),
|
||||
onPressed: isLoading ? null : _handleSubmit,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isLoading) ...[
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
const Text('로그인'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import 'package:superport_v2/core/constants/app_sections.dart';
|
||||
import 'package:superport_v2/widgets/app_layout.dart';
|
||||
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
||||
import 'package:superport_v2/widgets/components/superport_dialog.dart';
|
||||
import 'package:superport_v2/features/util/postal_search/presentation/models/postal_search_result.dart';
|
||||
import 'package:superport_v2/features/util/postal_search/presentation/widgets/postal_search_dialog.dart';
|
||||
|
||||
@@ -198,6 +199,33 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
),
|
||||
],
|
||||
toolbar: FilterBar(
|
||||
actions: [
|
||||
ShadButton.outline(
|
||||
onPressed: _controller.isLoading ? null : _applyFilters,
|
||||
child: const Text('검색 적용'),
|
||||
),
|
||||
if (showReset)
|
||||
ShadButton.ghost(
|
||||
onPressed: _controller.isLoading
|
||||
? null
|
||||
: () {
|
||||
_searchController.clear();
|
||||
_searchFocus.requestFocus();
|
||||
_controller.updateQuery('');
|
||||
_controller.updateTypeFilter(CustomerTypeFilter.all);
|
||||
_controller.updateStatusFilter(
|
||||
CustomerStatusFilter.all,
|
||||
);
|
||||
_updateRoute(
|
||||
page: 1,
|
||||
queryOverride: '',
|
||||
typeOverride: CustomerTypeFilter.all,
|
||||
statusOverride: CustomerStatusFilter.all,
|
||||
);
|
||||
},
|
||||
child: const Text('초기화'),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 260,
|
||||
@@ -251,31 +279,6 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
ShadButton.outline(
|
||||
onPressed: _controller.isLoading ? null : _applyFilters,
|
||||
child: const Text('검색 적용'),
|
||||
),
|
||||
if (showReset)
|
||||
ShadButton.ghost(
|
||||
onPressed: _controller.isLoading
|
||||
? null
|
||||
: () {
|
||||
_searchController.clear();
|
||||
_searchFocus.requestFocus();
|
||||
_controller.updateQuery('');
|
||||
_controller.updateTypeFilter(CustomerTypeFilter.all);
|
||||
_controller.updateStatusFilter(
|
||||
CustomerStatusFilter.all,
|
||||
);
|
||||
_updateRoute(
|
||||
page: 1,
|
||||
queryOverride: '',
|
||||
typeOverride: CustomerTypeFilter.all,
|
||||
statusOverride: CustomerStatusFilter.all,
|
||||
);
|
||||
},
|
||||
child: const Text('초기화'),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ShadCard(
|
||||
@@ -515,14 +518,41 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
final codeError = ValueNotifier<String?>(null);
|
||||
final nameError = ValueNotifier<String?>(null);
|
||||
final typeError = ValueNotifier<String?>(null);
|
||||
final zipcodeError = ValueNotifier<String?>(null);
|
||||
|
||||
await showDialog<bool>(
|
||||
context: parentContext,
|
||||
builder: (dialogContext) {
|
||||
final theme = ShadTheme.of(dialogContext);
|
||||
final materialTheme = Theme.of(dialogContext);
|
||||
final navigator = Navigator.of(dialogContext);
|
||||
Future<void> openPostalSearch() async {
|
||||
var isApplyingPostalSelection = false;
|
||||
|
||||
void handleZipcodeChange() {
|
||||
if (isApplyingPostalSelection) {
|
||||
return;
|
||||
}
|
||||
final text = zipcodeController.text.trim();
|
||||
final selection = selectedPostalNotifier.value;
|
||||
if (text.isEmpty) {
|
||||
if (selection != null) {
|
||||
selectedPostalNotifier.value = null;
|
||||
}
|
||||
zipcodeError.value = null;
|
||||
return;
|
||||
}
|
||||
if (selection != null && selection.zipcode != text) {
|
||||
selectedPostalNotifier.value = null;
|
||||
}
|
||||
if (zipcodeError.value != null) {
|
||||
zipcodeError.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
void handlePostalSelectionChange() {
|
||||
if (selectedPostalNotifier.value != null) {
|
||||
zipcodeError.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
zipcodeController.addListener(handleZipcodeChange);
|
||||
selectedPostalNotifier.addListener(handlePostalSelectionChange);
|
||||
|
||||
Future<void> openPostalSearch(BuildContext dialogContext) async {
|
||||
final keyword = zipcodeController.text.trim();
|
||||
final result = await showPostalSearchDialog(
|
||||
dialogContext,
|
||||
@@ -531,11 +561,11 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
isApplyingPostalSelection = true;
|
||||
zipcodeController
|
||||
..text = result.zipcode
|
||||
..selection = TextSelection.collapsed(
|
||||
offset: result.zipcode.length,
|
||||
);
|
||||
..selection = TextSelection.collapsed(offset: result.zipcode.length);
|
||||
isApplyingPostalSelection = false;
|
||||
selectedPostalNotifier.value = result;
|
||||
if (result.fullAddress.isNotEmpty) {
|
||||
addressController
|
||||
@@ -546,32 +576,15 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
}
|
||||
}
|
||||
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.all(24),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: ShadCard(
|
||||
title: Text(
|
||||
isEdit ? '고객사 수정' : '고객사 등록',
|
||||
style: theme.textTheme.h3,
|
||||
),
|
||||
description: Text(
|
||||
'고객사 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
footer: ValueListenableBuilder<bool>(
|
||||
await SuperportDialog.show<bool>(
|
||||
context: parentContext,
|
||||
dialog: SuperportDialog(
|
||||
title: isEdit ? '고객사 수정' : '고객사 등록',
|
||||
description: '고객사 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.',
|
||||
primaryAction: ValueListenableBuilder<bool>(
|
||||
valueListenable: saving,
|
||||
builder: (_, isSaving, __) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ShadButton.ghost(
|
||||
onPressed: isSaving ? null : () => navigator.pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ShadButton(
|
||||
builder: (context, isSaving, _) {
|
||||
return ShadButton(
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () async {
|
||||
@@ -584,12 +597,13 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
final note = noteController.text.trim();
|
||||
final partner = partnerNotifier.value;
|
||||
var general = generalNotifier.value;
|
||||
final selectedPostal = selectedPostalNotifier.value;
|
||||
|
||||
codeError.value = code.isEmpty
|
||||
? '고객사코드를 입력하세요.'
|
||||
: null;
|
||||
nameError.value = name.isEmpty
|
||||
? '고객사명을 입력하세요.'
|
||||
codeError.value = code.isEmpty ? '고객사코드를 입력하세요.' : null;
|
||||
nameError.value = name.isEmpty ? '고객사명을 입력하세요.' : null;
|
||||
zipcodeError.value =
|
||||
zipcode.isNotEmpty && selectedPostal == null
|
||||
? '우편번호 검색으로 주소를 선택하세요.'
|
||||
: null;
|
||||
|
||||
if (!partner && !general) {
|
||||
@@ -603,6 +617,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
|
||||
if (codeError.value != null ||
|
||||
nameError.value != null ||
|
||||
zipcodeError.value != null ||
|
||||
typeError.value != null) {
|
||||
return;
|
||||
}
|
||||
@@ -616,17 +631,13 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
email: email.isEmpty ? null : email,
|
||||
mobileNo: mobile.isEmpty ? null : mobile,
|
||||
zipcode: zipcode.isEmpty ? null : zipcode,
|
||||
addressDetail: address.isEmpty
|
||||
? null
|
||||
: address,
|
||||
addressDetail: address.isEmpty ? null : address,
|
||||
isActive: isActiveNotifier.value,
|
||||
note: note.isEmpty ? null : note,
|
||||
);
|
||||
final navigator = Navigator.of(context);
|
||||
final response = isEdit
|
||||
? await _controller.update(
|
||||
customerId!,
|
||||
input,
|
||||
)
|
||||
? await _controller.update(customerId!, input)
|
||||
: await _controller.create(input);
|
||||
saving.value = false;
|
||||
if (response != null) {
|
||||
@@ -634,20 +645,33 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
return;
|
||||
}
|
||||
if (mounted) {
|
||||
_showSnack(
|
||||
isEdit ? '고객사를 수정했습니다.' : '고객사를 등록했습니다.',
|
||||
);
|
||||
_showSnack(isEdit ? '고객사를 수정했습니다.' : '고객사를 등록했습니다.');
|
||||
}
|
||||
navigator.pop(true);
|
||||
}
|
||||
},
|
||||
child: Text(isEdit ? '저장' : '등록'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
secondaryAction: ValueListenableBuilder<bool>(
|
||||
valueListenable: saving,
|
||||
builder: (context, isSaving, _) {
|
||||
return ShadButton.ghost(
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () => Navigator.of(context).pop(false),
|
||||
child: const Text('취소'),
|
||||
);
|
||||
},
|
||||
),
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: saving,
|
||||
builder: (context, isSaving, _) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final materialTheme = Theme.of(context);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -726,7 +750,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
return ValueListenableBuilder<String?>(
|
||||
valueListenable: typeError,
|
||||
builder: (_, errorText, __) {
|
||||
final onChanged = saving.value
|
||||
final onChanged = isSaving
|
||||
? null
|
||||
: (bool? value) {
|
||||
if (value == null) return;
|
||||
@@ -738,7 +762,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
typeError.value = null;
|
||||
}
|
||||
};
|
||||
final onChangedGeneral = saving.value
|
||||
final onChangedGeneral = isSaving
|
||||
? null
|
||||
: (bool? value) {
|
||||
if (value == null) return;
|
||||
@@ -753,8 +777,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
return _FormField(
|
||||
label: '유형',
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
@@ -775,16 +798,12 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
),
|
||||
if (errorText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 6,
|
||||
),
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
errorText,
|
||||
style: theme.textTheme.small
|
||||
.copyWith(
|
||||
color: materialTheme
|
||||
.colorScheme
|
||||
.error,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color:
|
||||
materialTheme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -814,7 +833,10 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_FormField(
|
||||
ValueListenableBuilder<String?>(
|
||||
valueListenable: zipcodeError,
|
||||
builder: (_, zipcodeErrorText, __) {
|
||||
return _FormField(
|
||||
label: '우편번호',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -830,9 +852,9 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ShadButton.outline(
|
||||
onPressed: saving.value
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: openPostalSearch,
|
||||
: () => openPostalSearch(context),
|
||||
child: const Text('검색'),
|
||||
),
|
||||
],
|
||||
@@ -858,8 +880,20 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
);
|
||||
},
|
||||
),
|
||||
if (zipcodeErrorText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
zipcodeErrorText,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: materialTheme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_FormField(
|
||||
@@ -879,7 +913,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
children: [
|
||||
ShadSwitch(
|
||||
value: value,
|
||||
onChanged: saving.value
|
||||
onChanged: isSaving
|
||||
? null
|
||||
: (next) => isActiveNotifier.value = next,
|
||||
),
|
||||
@@ -898,12 +932,13 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
if (existing != null) ..._buildAuditInfo(existing, theme),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
zipcodeController.removeListener(handleZipcodeChange);
|
||||
selectedPostalNotifier.removeListener(handlePostalSelectionChange);
|
||||
|
||||
codeController.dispose();
|
||||
nameController.dispose();
|
||||
@@ -920,27 +955,26 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
codeError.dispose();
|
||||
nameError.dispose();
|
||||
typeError.dispose();
|
||||
zipcodeError.dispose();
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(Customer customer) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
final confirmed = await SuperportDialog.show<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('고객사 삭제'),
|
||||
content: Text('"${customer.customerName}" 고객사를 삭제하시겠습니까?'),
|
||||
dialog: SuperportDialog(
|
||||
title: '고객사 삭제',
|
||||
description: '"${customer.customerName}" 고객사를 삭제하시겠습니까?',
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
ShadButton.ghost(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
ShadButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('삭제'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && customer.id != null) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import 'package:superport_v2/core/constants/app_sections.dart';
|
||||
import 'package:superport_v2/widgets/app_layout.dart';
|
||||
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
||||
import 'package:superport_v2/widgets/components/superport_dialog.dart';
|
||||
|
||||
import '../../../../../core/config/environment.dart';
|
||||
import '../../../../../widgets/spec_page.dart';
|
||||
@@ -130,7 +131,8 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> {
|
||||
? false
|
||||
: (result.page * result.pageSize) < result.total;
|
||||
|
||||
final showReset = _searchController.text.isNotEmpty ||
|
||||
final showReset =
|
||||
_searchController.text.isNotEmpty ||
|
||||
_controller.defaultFilter != GroupDefaultFilter.all ||
|
||||
_controller.statusFilter != GroupStatusFilter.all;
|
||||
|
||||
@@ -145,12 +147,35 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> {
|
||||
actions: [
|
||||
ShadButton(
|
||||
leading: const Icon(LucideIcons.plus, size: 16),
|
||||
onPressed:
|
||||
_controller.isSubmitting ? null : () => _openGroupForm(context),
|
||||
onPressed: _controller.isSubmitting
|
||||
? null
|
||||
: () => _openGroupForm(context),
|
||||
child: const Text('신규 등록'),
|
||||
),
|
||||
],
|
||||
toolbar: FilterBar(
|
||||
actions: [
|
||||
ShadButton.outline(
|
||||
onPressed: _controller.isLoading ? null : _applyFilters,
|
||||
child: const Text('검색 적용'),
|
||||
),
|
||||
if (showReset)
|
||||
ShadButton.ghost(
|
||||
onPressed: _controller.isLoading
|
||||
? null
|
||||
: () {
|
||||
_searchController.clear();
|
||||
_searchFocus.requestFocus();
|
||||
_controller.updateQuery('');
|
||||
_controller.updateDefaultFilter(
|
||||
GroupDefaultFilter.all,
|
||||
);
|
||||
_controller.updateStatusFilter(GroupStatusFilter.all);
|
||||
_controller.fetch(page: 1);
|
||||
},
|
||||
child: const Text('초기화'),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 260,
|
||||
@@ -206,28 +231,6 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> {
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
ShadButton.outline(
|
||||
onPressed: _controller.isLoading ? null : _applyFilters,
|
||||
child: const Text('검색 적용'),
|
||||
),
|
||||
if (showReset)
|
||||
ShadButton.ghost(
|
||||
onPressed: _controller.isLoading
|
||||
? null
|
||||
: () {
|
||||
_searchController.clear();
|
||||
_searchFocus.requestFocus();
|
||||
_controller.updateQuery('');
|
||||
_controller.updateDefaultFilter(
|
||||
GroupDefaultFilter.all,
|
||||
);
|
||||
_controller.updateStatusFilter(
|
||||
GroupStatusFilter.all,
|
||||
);
|
||||
_controller.fetch(page: 1);
|
||||
},
|
||||
child: const Text('초기화'),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ShadCard(
|
||||
@@ -285,12 +288,8 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> {
|
||||
onEdit: _controller.isSubmitting
|
||||
? null
|
||||
: (group) => _openGroupForm(context, group: group),
|
||||
onDelete: _controller.isSubmitting
|
||||
? null
|
||||
: _confirmDelete,
|
||||
onRestore: _controller.isSubmitting
|
||||
? null
|
||||
: _restoreGroup,
|
||||
onDelete: _controller.isSubmitting ? null : _confirmDelete,
|
||||
onRestore: _controller.isSubmitting ? null : _restoreGroup,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -352,49 +351,36 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> {
|
||||
final saving = ValueNotifier<bool>(false);
|
||||
final nameError = ValueNotifier<String?>(null);
|
||||
|
||||
await showDialog<bool>(
|
||||
await SuperportDialog.show<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
final theme = ShadTheme.of(dialogContext);
|
||||
final materialTheme = Theme.of(dialogContext);
|
||||
final navigator = Navigator.of(dialogContext);
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.all(24),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ConstrainedBox(
|
||||
dialog: SuperportDialog(
|
||||
title: isEdit ? '그룹 수정' : '그룹 등록',
|
||||
description: '그룹 정보를 ${isEdit ? '수정' : '입력'}하세요.',
|
||||
constraints: const BoxConstraints(maxWidth: 540),
|
||||
child: ShadCard(
|
||||
title: Text(
|
||||
isEdit ? '그룹 수정' : '그룹 등록',
|
||||
style: theme.textTheme.h3,
|
||||
),
|
||||
description: Text(
|
||||
'그룹 정보를 ${isEdit ? '수정' : '입력'}하세요.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
footer: ValueListenableBuilder<bool>(
|
||||
actions: [
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: saving,
|
||||
builder: (_, isSaving, __) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ShadButton.ghost(
|
||||
onPressed: isSaving ? null : () => navigator.pop(false),
|
||||
builder: (dialogContext, isSaving, __) {
|
||||
return ShadButton.ghost(
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('취소'),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ShadButton(
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: saving,
|
||||
builder: (dialogContext, isSaving, __) {
|
||||
return ShadButton(
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () async {
|
||||
final name = nameController.text.trim();
|
||||
final description = descriptionController.text
|
||||
.trim();
|
||||
final description = descriptionController.text.trim();
|
||||
final note = noteController.text.trim();
|
||||
|
||||
nameError.value = name.isEmpty
|
||||
? '그룹명을 입력하세요.'
|
||||
: null;
|
||||
nameError.value = name.isEmpty ? '그룹명을 입력하세요.' : null;
|
||||
|
||||
if (nameError.value != null) {
|
||||
return;
|
||||
@@ -403,13 +389,12 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> {
|
||||
saving.value = true;
|
||||
final input = GroupInput(
|
||||
groupName: name,
|
||||
description: description.isEmpty
|
||||
? null
|
||||
: description,
|
||||
description: description.isEmpty ? null : description,
|
||||
isDefault: isDefaultNotifier.value,
|
||||
isActive: isActiveNotifier.value,
|
||||
note: note.isEmpty ? null : note,
|
||||
);
|
||||
final navigator = Navigator.of(dialogContext);
|
||||
final response = isEdit
|
||||
? await _controller.update(groupId!, input)
|
||||
: await _controller.create(input);
|
||||
@@ -419,20 +404,22 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> {
|
||||
return;
|
||||
}
|
||||
if (mounted) {
|
||||
_showSnack(
|
||||
isEdit ? '그룹을 수정했습니다.' : '그룹을 등록했습니다.',
|
||||
);
|
||||
_showSnack(isEdit ? '그룹을 수정했습니다.' : '그룹을 등록했습니다.');
|
||||
}
|
||||
navigator.pop(true);
|
||||
navigator.pop();
|
||||
}
|
||||
},
|
||||
child: Text(isEdit ? '저장' : '등록'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
child: SizedBox(
|
||||
],
|
||||
child: StatefulBuilder(
|
||||
builder: (dialogContext, _) {
|
||||
final theme = ShadTheme.of(dialogContext);
|
||||
final materialTheme = Theme.of(dialogContext);
|
||||
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
@@ -489,8 +476,7 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> {
|
||||
value: value,
|
||||
onChanged: saving.value
|
||||
? null
|
||||
: (next) =>
|
||||
isDefaultNotifier.value = next,
|
||||
: (next) => isDefaultNotifier.value = next,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(value ? '기본 그룹' : '일반 그룹'),
|
||||
@@ -525,7 +511,7 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> {
|
||||
label: '비고',
|
||||
child: ShadTextarea(controller: noteController),
|
||||
),
|
||||
if (isEdit) ...[
|
||||
if (existingGroup != null) ...[
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'생성일시: ${_formatDateTime(existingGroup.createdAt)}',
|
||||
@@ -540,11 +526,10 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
nameController.dispose();
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import 'package:superport_v2/core/constants/app_sections.dart';
|
||||
import 'package:superport_v2/widgets/app_layout.dart';
|
||||
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
||||
import 'package:superport_v2/widgets/components/superport_dialog.dart';
|
||||
|
||||
import '../../../../../core/config/environment.dart';
|
||||
import '../../../../../widgets/spec_page.dart';
|
||||
@@ -167,7 +168,8 @@ class _GroupPermissionEnabledPageState
|
||||
? false
|
||||
: (result.page * result.pageSize) < result.total;
|
||||
|
||||
final showReset = _searchController.text.isNotEmpty ||
|
||||
final showReset =
|
||||
_searchController.text.isNotEmpty ||
|
||||
_controller.groupFilter != null ||
|
||||
_controller.menuFilter != null ||
|
||||
_controller.statusFilter != GroupPermissionStatusFilter.all ||
|
||||
@@ -191,6 +193,29 @@ class _GroupPermissionEnabledPageState
|
||||
),
|
||||
],
|
||||
toolbar: FilterBar(
|
||||
actions: [
|
||||
ShadButton.outline(
|
||||
onPressed: _controller.isLoading ? null : _applyFilters,
|
||||
child: const Text('검색 적용'),
|
||||
),
|
||||
if (showReset)
|
||||
ShadButton.ghost(
|
||||
onPressed: _controller.isLoading
|
||||
? null
|
||||
: () {
|
||||
_searchController.clear();
|
||||
_searchFocus.requestFocus();
|
||||
_controller.updateGroupFilter(null);
|
||||
_controller.updateMenuFilter(null);
|
||||
_controller.updateIncludeDeleted(false);
|
||||
_controller.updateStatusFilter(
|
||||
GroupPermissionStatusFilter.all,
|
||||
);
|
||||
_controller.fetch(page: 1);
|
||||
},
|
||||
child: const Text('초기화'),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 260,
|
||||
@@ -208,16 +233,12 @@ class _GroupPermissionEnabledPageState
|
||||
key: ValueKey(_controller.groupFilter),
|
||||
initialValue: _controller.groupFilter,
|
||||
placeholder: Text(
|
||||
_controller.groups.isEmpty
|
||||
? '그룹 로딩중...'
|
||||
: '그룹 전체',
|
||||
_controller.groups.isEmpty ? '그룹 로딩중...' : '그룹 전체',
|
||||
),
|
||||
selectedOptionBuilder: (context, value) {
|
||||
if (value == null) {
|
||||
return Text(
|
||||
_controller.groups.isEmpty
|
||||
? '그룹 로딩중...'
|
||||
: '그룹 전체',
|
||||
_controller.groups.isEmpty ? '그룹 로딩중...' : '그룹 전체',
|
||||
);
|
||||
}
|
||||
final group = _controller.groups.firstWhere(
|
||||
@@ -230,10 +251,7 @@ class _GroupPermissionEnabledPageState
|
||||
_controller.updateGroupFilter(value);
|
||||
},
|
||||
options: [
|
||||
const ShadOption<int?>(
|
||||
value: null,
|
||||
child: Text('그룹 전체'),
|
||||
),
|
||||
const ShadOption<int?>(value: null, child: Text('그룹 전체')),
|
||||
..._controller.groups.map(
|
||||
(group) => ShadOption<int?>(
|
||||
value: group.id,
|
||||
@@ -249,25 +267,18 @@ class _GroupPermissionEnabledPageState
|
||||
key: ValueKey(_controller.menuFilter),
|
||||
initialValue: _controller.menuFilter,
|
||||
placeholder: Text(
|
||||
_controller.menus.isEmpty
|
||||
? '메뉴 로딩중...'
|
||||
: '메뉴 전체',
|
||||
_controller.menus.isEmpty ? '메뉴 로딩중...' : '메뉴 전체',
|
||||
),
|
||||
selectedOptionBuilder: (context, value) {
|
||||
if (value == null) {
|
||||
return Text(
|
||||
_controller.menus.isEmpty
|
||||
? '메뉴 로딩중...'
|
||||
: '메뉴 전체',
|
||||
_controller.menus.isEmpty ? '메뉴 로딩중...' : '메뉴 전체',
|
||||
);
|
||||
}
|
||||
final menuItem = _controller.menus.firstWhere(
|
||||
(m) => m.id == value,
|
||||
orElse: () => MenuItem(
|
||||
id: value,
|
||||
menuCode: '',
|
||||
menuName: '',
|
||||
),
|
||||
orElse: () =>
|
||||
MenuItem(id: value, menuCode: '', menuName: ''),
|
||||
);
|
||||
return Text(menuItem.menuName);
|
||||
},
|
||||
@@ -275,10 +286,7 @@ class _GroupPermissionEnabledPageState
|
||||
_controller.updateMenuFilter(value);
|
||||
},
|
||||
options: [
|
||||
const ShadOption<int?>(
|
||||
value: null,
|
||||
child: Text('메뉴 전체'),
|
||||
),
|
||||
const ShadOption<int?>(value: null, child: Text('메뉴 전체')),
|
||||
..._controller.menus.map(
|
||||
(menuItem) => ShadOption<int?>(
|
||||
value: menuItem.id,
|
||||
@@ -322,24 +330,6 @@ class _GroupPermissionEnabledPageState
|
||||
const Text('삭제 포함'),
|
||||
],
|
||||
),
|
||||
ShadButton.outline(
|
||||
onPressed: _controller.isLoading ? null : _applyFilters,
|
||||
child: const Text('검색 적용'),
|
||||
),
|
||||
if (showReset)
|
||||
ShadButton.ghost(
|
||||
onPressed: _controller.isLoading
|
||||
? null
|
||||
: () {
|
||||
_searchController.clear();
|
||||
_searchFocus.requestFocus();
|
||||
_controller.updateGroupFilter(null);
|
||||
_controller.updateMenuFilter(null);
|
||||
_controller.updateIncludeDeleted(false);
|
||||
_controller.fetch(page: 1);
|
||||
},
|
||||
child: const Text('초기화'),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ShadCard(
|
||||
@@ -396,11 +386,11 @@ class _GroupPermissionEnabledPageState
|
||||
dateFormat: _dateFormat,
|
||||
onEdit: _controller.isSubmitting
|
||||
? null
|
||||
: (permission) =>
|
||||
_openPermissionForm(context, permission: permission),
|
||||
onDelete: _controller.isSubmitting
|
||||
? null
|
||||
: _confirmDelete,
|
||||
: (permission) => _openPermissionForm(
|
||||
context,
|
||||
permission: permission,
|
||||
),
|
||||
onDelete: _controller.isSubmitting ? null : _confirmDelete,
|
||||
onRestore: _controller.isSubmitting
|
||||
? null
|
||||
: _restorePermission,
|
||||
@@ -430,74 +420,72 @@ class _GroupPermissionEnabledPageState
|
||||
BuildContext context, {
|
||||
GroupPermission? permission,
|
||||
}) async {
|
||||
final isEdit = permission != null;
|
||||
final permissionId = permission?.id;
|
||||
final existingPermission = permission;
|
||||
final isEdit = existingPermission != null;
|
||||
final permissionId = existingPermission?.id;
|
||||
if (isEdit && permissionId == null) {
|
||||
_showSnack('ID 정보가 없어 수정할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
final groupNotifier = ValueNotifier<int?>(permission?.group.id);
|
||||
final menuNotifier = ValueNotifier<int?>(permission?.menu.id);
|
||||
final createNotifier = ValueNotifier<bool>(permission?.canCreate ?? false);
|
||||
final readNotifier = ValueNotifier<bool>(permission?.canRead ?? true);
|
||||
final updateNotifier = ValueNotifier<bool>(permission?.canUpdate ?? false);
|
||||
final deleteNotifier = ValueNotifier<bool>(permission?.canDelete ?? false);
|
||||
final activeNotifier = ValueNotifier<bool>(permission?.isActive ?? true);
|
||||
final noteController = TextEditingController(text: permission?.note ?? '');
|
||||
final groupNotifier = ValueNotifier<int?>(existingPermission?.group.id);
|
||||
final menuNotifier = ValueNotifier<int?>(existingPermission?.menu.id);
|
||||
final createNotifier = ValueNotifier<bool>(
|
||||
existingPermission?.canCreate ?? false,
|
||||
);
|
||||
final readNotifier = ValueNotifier<bool>(
|
||||
existingPermission?.canRead ?? true,
|
||||
);
|
||||
final updateNotifier = ValueNotifier<bool>(
|
||||
existingPermission?.canUpdate ?? false,
|
||||
);
|
||||
final deleteNotifier = ValueNotifier<bool>(
|
||||
existingPermission?.canDelete ?? false,
|
||||
);
|
||||
final activeNotifier = ValueNotifier<bool>(
|
||||
existingPermission?.isActive ?? true,
|
||||
);
|
||||
final noteController = TextEditingController(
|
||||
text: existingPermission?.note ?? '',
|
||||
);
|
||||
final saving = ValueNotifier<bool>(false);
|
||||
final groupError = ValueNotifier<String?>(null);
|
||||
final menuError = ValueNotifier<String?>(null);
|
||||
|
||||
await showDialog<bool>(
|
||||
await SuperportDialog.show<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
final theme = ShadTheme.of(dialogContext);
|
||||
final materialTheme = Theme.of(dialogContext);
|
||||
final navigator = Navigator.of(dialogContext);
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.all(24),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ConstrainedBox(
|
||||
dialog: SuperportDialog(
|
||||
title: isEdit ? '권한 수정' : '권한 등록',
|
||||
description: '그룹과 메뉴의 권한을 ${isEdit ? '수정' : '등록'}하세요.',
|
||||
constraints: const BoxConstraints(maxWidth: 600),
|
||||
child: ShadCard(
|
||||
title: Text(
|
||||
isEdit ? '권한 수정' : '권한 등록',
|
||||
style: theme.textTheme.h3,
|
||||
),
|
||||
description: Text(
|
||||
'그룹과 메뉴의 권한을 ${isEdit ? '수정' : '등록'}하세요.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
footer: ValueListenableBuilder<bool>(
|
||||
secondaryAction: ValueListenableBuilder<bool>(
|
||||
valueListenable: saving,
|
||||
builder: (_, isSaving, __) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ShadButton.ghost(
|
||||
onPressed: isSaving ? null : () => navigator.pop(false),
|
||||
builder: (dialogContext, isSaving, __) {
|
||||
return ShadButton.ghost(
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('취소'),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ShadButton(
|
||||
primaryAction: ValueListenableBuilder<bool>(
|
||||
valueListenable: saving,
|
||||
builder: (dialogContext, isSaving, __) {
|
||||
return ShadButton(
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () async {
|
||||
final groupId = groupNotifier.value;
|
||||
final menuId = menuNotifier.value;
|
||||
groupError.value = groupId == null
|
||||
? '그룹을 선택하세요.'
|
||||
: null;
|
||||
menuError.value = menuId == null
|
||||
? '메뉴를 선택하세요.'
|
||||
: null;
|
||||
if (groupError.value != null ||
|
||||
menuError.value != null) {
|
||||
groupError.value = groupId == null ? '그룹을 선택하세요.' : null;
|
||||
menuError.value = menuId == null ? '메뉴를 선택하세요.' : null;
|
||||
if (groupError.value != null || menuError.value != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
final trimmedNote = noteController.text.trim();
|
||||
final input = GroupPermissionInput(
|
||||
groupId: groupId!,
|
||||
menuId: menuId!,
|
||||
@@ -506,15 +494,11 @@ class _GroupPermissionEnabledPageState
|
||||
canUpdate: updateNotifier.value,
|
||||
canDelete: deleteNotifier.value,
|
||||
isActive: activeNotifier.value,
|
||||
note: noteController.text.trim().isEmpty
|
||||
? null
|
||||
: noteController.text.trim(),
|
||||
note: trimmedNote.isEmpty ? null : trimmedNote,
|
||||
);
|
||||
final navigator = Navigator.of(dialogContext);
|
||||
final response = isEdit
|
||||
? await _controller.update(
|
||||
permissionId!,
|
||||
input,
|
||||
)
|
||||
? await _controller.update(permissionId!, input)
|
||||
: await _controller.create(input);
|
||||
saving.value = false;
|
||||
if (response != null) {
|
||||
@@ -522,22 +506,21 @@ class _GroupPermissionEnabledPageState
|
||||
return;
|
||||
}
|
||||
if (mounted) {
|
||||
_showSnack(
|
||||
isEdit ? '권한을 수정했습니다.' : '권한을 등록했습니다.',
|
||||
);
|
||||
_showSnack(isEdit ? '권한을 수정했습니다.' : '권한을 등록했습니다.');
|
||||
}
|
||||
navigator.pop(true);
|
||||
}
|
||||
},
|
||||
child: Text(isEdit ? '저장' : '등록'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: SingleChildScrollView(
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: saving,
|
||||
builder: (dialogContext, isSaving, __) {
|
||||
final theme = ShadTheme.of(dialogContext);
|
||||
final materialTheme = Theme.of(dialogContext);
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -570,7 +553,7 @@ class _GroupPermissionEnabledPageState
|
||||
: group.groupName,
|
||||
);
|
||||
},
|
||||
onChanged: saving.value || isEdit
|
||||
onChanged: isSaving || isEdit
|
||||
? null
|
||||
: (value) {
|
||||
groupNotifier.value = value;
|
||||
@@ -633,7 +616,7 @@ class _GroupPermissionEnabledPageState
|
||||
: menu.menuName,
|
||||
);
|
||||
},
|
||||
onChanged: saving.value || isEdit
|
||||
onChanged: isSaving || isEdit
|
||||
? null
|
||||
: (value) {
|
||||
menuNotifier.value = value;
|
||||
@@ -669,25 +652,25 @@ class _GroupPermissionEnabledPageState
|
||||
_PermissionToggleRow(
|
||||
label: '생성권한',
|
||||
notifier: createNotifier,
|
||||
enabled: !saving.value,
|
||||
enabled: !isSaving,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_PermissionToggleRow(
|
||||
label: '조회권한',
|
||||
notifier: readNotifier,
|
||||
enabled: !saving.value,
|
||||
enabled: !isSaving,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_PermissionToggleRow(
|
||||
label: '수정권한',
|
||||
notifier: updateNotifier,
|
||||
enabled: !saving.value,
|
||||
enabled: !isSaving,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_PermissionToggleRow(
|
||||
label: '삭제권한',
|
||||
notifier: deleteNotifier,
|
||||
enabled: !saving.value,
|
||||
enabled: !isSaving,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ValueListenableBuilder<bool>(
|
||||
@@ -699,7 +682,7 @@ class _GroupPermissionEnabledPageState
|
||||
children: [
|
||||
ShadSwitch(
|
||||
value: value,
|
||||
onChanged: saving.value
|
||||
onChanged: isSaving
|
||||
? null
|
||||
: (next) => activeNotifier.value = next,
|
||||
),
|
||||
@@ -715,26 +698,24 @@ class _GroupPermissionEnabledPageState
|
||||
label: '비고',
|
||||
child: ShadTextarea(controller: noteController),
|
||||
),
|
||||
if (isEdit) ...[
|
||||
if (existingPermission != null) ...[
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'생성일시: ${_formatDateTime(permission.createdAt)}',
|
||||
'생성일시: ${_formatDateTime(existingPermission.createdAt)}',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'수정일시: ${_formatDateTime(permission.updatedAt)}',
|
||||
'수정일시: ${_formatDateTime(existingPermission.updatedAt)}',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
groupNotifier.dispose();
|
||||
@@ -751,26 +732,29 @@ class _GroupPermissionEnabledPageState
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(GroupPermission permission) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
final confirmed = await SuperportDialog.show<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('권한 삭제'),
|
||||
content: Text(
|
||||
dialog: SuperportDialog(
|
||||
title: '권한 삭제',
|
||||
description:
|
||||
'"${permission.group.groupName}" → "${permission.menu.menuName}" 권한을 삭제하시겠습니까?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
secondaryAction: Builder(
|
||||
builder: (dialogContext) {
|
||||
return ShadButton.ghost(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: const Text('삭제'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
primaryAction: Builder(
|
||||
builder: (dialogContext) {
|
||||
return ShadButton.destructive(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: const Text('삭제'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && permission.id != null) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import 'package:superport_v2/core/constants/app_sections.dart';
|
||||
import 'package:superport_v2/widgets/app_layout.dart';
|
||||
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
||||
import 'package:superport_v2/widgets/components/superport_dialog.dart';
|
||||
|
||||
import '../../../../../core/config/environment.dart';
|
||||
import '../../../../../widgets/spec_page.dart';
|
||||
@@ -151,7 +152,8 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
|
||||
? false
|
||||
: (result.page * result.pageSize) < result.total;
|
||||
|
||||
final showReset = _searchController.text.isNotEmpty ||
|
||||
final showReset =
|
||||
_searchController.text.isNotEmpty ||
|
||||
_controller.parentFilter != null ||
|
||||
_controller.statusFilter != menu.MenuStatusFilter.all ||
|
||||
_controller.includeDeleted;
|
||||
@@ -167,12 +169,36 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
|
||||
actions: [
|
||||
ShadButton(
|
||||
leading: const Icon(LucideIcons.plus, size: 16),
|
||||
onPressed:
|
||||
_controller.isSubmitting ? null : () => _openMenuForm(context),
|
||||
onPressed: _controller.isSubmitting
|
||||
? null
|
||||
: () => _openMenuForm(context),
|
||||
child: const Text('신규 등록'),
|
||||
),
|
||||
],
|
||||
toolbar: FilterBar(
|
||||
actions: [
|
||||
ShadButton.outline(
|
||||
onPressed: _controller.isLoading ? null : _applyFilters,
|
||||
child: const Text('검색 적용'),
|
||||
),
|
||||
if (showReset)
|
||||
ShadButton.ghost(
|
||||
onPressed: _controller.isLoading
|
||||
? null
|
||||
: () {
|
||||
_searchController.clear();
|
||||
_searchFocus.requestFocus();
|
||||
_controller.updateQuery('');
|
||||
_controller.updateParentFilter(null);
|
||||
_controller.updateStatusFilter(
|
||||
menu.MenuStatusFilter.all,
|
||||
);
|
||||
_controller.updateIncludeDeleted(false);
|
||||
_controller.fetch(page: 1);
|
||||
},
|
||||
child: const Text('초기화'),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 260,
|
||||
@@ -195,18 +221,13 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
|
||||
selectedOptionBuilder: (context, value) {
|
||||
if (value == null) {
|
||||
return Text(
|
||||
_controller.isLoadingParents
|
||||
? '상위 로딩중...'
|
||||
: '상위 전체',
|
||||
_controller.isLoadingParents ? '상위 로딩중...' : '상위 전체',
|
||||
);
|
||||
}
|
||||
final target = _controller.parents.firstWhere(
|
||||
(menuItem) => menuItem.id == value,
|
||||
orElse: () => MenuItem(
|
||||
id: value,
|
||||
menuCode: '',
|
||||
menuName: '',
|
||||
),
|
||||
orElse: () =>
|
||||
MenuItem(id: value, menuCode: '', menuName: ''),
|
||||
);
|
||||
final label = target.menuName.isEmpty
|
||||
? '상위 전체'
|
||||
@@ -220,10 +241,7 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
|
||||
_controller.fetch(page: 1);
|
||||
},
|
||||
options: [
|
||||
const ShadOption<int?>(
|
||||
value: null,
|
||||
child: Text('상위 전체'),
|
||||
),
|
||||
const ShadOption<int?>(value: null, child: Text('상위 전체')),
|
||||
..._controller.parents.map(
|
||||
(menuItem) => ShadOption<int?>(
|
||||
value: menuItem.id,
|
||||
@@ -269,27 +287,6 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
|
||||
const Text('삭제 포함'),
|
||||
],
|
||||
),
|
||||
ShadButton.outline(
|
||||
onPressed: _controller.isLoading ? null : _applyFilters,
|
||||
child: const Text('검색 적용'),
|
||||
),
|
||||
if (showReset)
|
||||
ShadButton.ghost(
|
||||
onPressed: _controller.isLoading
|
||||
? null
|
||||
: () {
|
||||
_searchController.clear();
|
||||
_searchFocus.requestFocus();
|
||||
_controller.updateQuery('');
|
||||
_controller.updateParentFilter(null);
|
||||
_controller.updateStatusFilter(
|
||||
menu.MenuStatusFilter.all,
|
||||
);
|
||||
_controller.updateIncludeDeleted(false);
|
||||
_controller.fetch(page: 1);
|
||||
},
|
||||
child: const Text('초기화'),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ShadCard(
|
||||
@@ -346,14 +343,9 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
|
||||
dateFormat: _dateFormat,
|
||||
onEdit: _controller.isSubmitting
|
||||
? null
|
||||
: (menuItem) =>
|
||||
_openMenuForm(context, menu: menuItem),
|
||||
onDelete: _controller.isSubmitting
|
||||
? null
|
||||
: _confirmDelete,
|
||||
onRestore: _controller.isSubmitting
|
||||
? null
|
||||
: _restoreMenu,
|
||||
: (menuItem) => _openMenuForm(context, menu: menuItem),
|
||||
onDelete: _controller.isSubmitting ? null : _confirmDelete,
|
||||
onRestore: _controller.isSubmitting ? null : _restoreMenu,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -410,38 +402,27 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
|
||||
final nameError = ValueNotifier<String?>(null);
|
||||
final orderError = ValueNotifier<String?>(null);
|
||||
|
||||
await showDialog<bool>(
|
||||
await SuperportDialog.show<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
final theme = ShadTheme.of(dialogContext);
|
||||
final materialTheme = Theme.of(dialogContext);
|
||||
final navigator = Navigator.of(dialogContext);
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.all(24),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ConstrainedBox(
|
||||
dialog: SuperportDialog(
|
||||
title: isEdit ? '메뉴 수정' : '메뉴 등록',
|
||||
description: '메뉴 정보를 ${isEdit ? '수정' : '입력'}하세요.',
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: ShadCard(
|
||||
title: Text(
|
||||
isEdit ? '메뉴 수정' : '메뉴 등록',
|
||||
style: theme.textTheme.h3,
|
||||
),
|
||||
description: Text(
|
||||
'메뉴 정보를 ${isEdit ? '수정' : '입력'}하세요.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
footer: ValueListenableBuilder<bool>(
|
||||
secondaryAction: ValueListenableBuilder<bool>(
|
||||
valueListenable: saving,
|
||||
builder: (_, isSaving, __) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ShadButton.ghost(
|
||||
onPressed: isSaving ? null : () => navigator.pop(false),
|
||||
builder: (dialogContext, isSaving, __) {
|
||||
return ShadButton.ghost(
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('취소'),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ShadButton(
|
||||
primaryAction: ValueListenableBuilder<bool>(
|
||||
valueListenable: saving,
|
||||
builder: (dialogContext, isSaving, __) {
|
||||
return ShadButton(
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () async {
|
||||
@@ -451,21 +432,15 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
|
||||
final orderText = orderController.text.trim();
|
||||
final note = noteController.text.trim();
|
||||
|
||||
codeError.value = code.isEmpty
|
||||
? '메뉴코드를 입력하세요.'
|
||||
: null;
|
||||
nameError.value = name.isEmpty
|
||||
? '메뉴명을 입력하세요.'
|
||||
: null;
|
||||
codeError.value = code.isEmpty ? '메뉴코드를 입력하세요.' : null;
|
||||
nameError.value = name.isEmpty ? '메뉴명을 입력하세요.' : null;
|
||||
|
||||
int? orderValue;
|
||||
if (orderText.isNotEmpty) {
|
||||
orderValue = int.tryParse(orderText);
|
||||
if (orderValue == null) {
|
||||
orderError.value = '표시순서는 숫자여야 합니다.';
|
||||
} else {
|
||||
orderError.value = null;
|
||||
}
|
||||
orderError.value = orderValue == null
|
||||
? '표시순서는 숫자여야 합니다.'
|
||||
: null;
|
||||
} else {
|
||||
orderError.value = null;
|
||||
}
|
||||
@@ -486,6 +461,7 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
|
||||
isActive: isActiveNotifier.value,
|
||||
note: note.isEmpty ? null : note,
|
||||
);
|
||||
final navigator = Navigator.of(dialogContext);
|
||||
final response = isEdit
|
||||
? await _controller.update(menuId!, input)
|
||||
: await _controller.create(input);
|
||||
@@ -495,22 +471,21 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
|
||||
return;
|
||||
}
|
||||
if (mounted) {
|
||||
_showSnack(
|
||||
isEdit ? '메뉴를 수정했습니다.' : '메뉴를 등록했습니다.',
|
||||
);
|
||||
_showSnack(isEdit ? '메뉴를 수정했습니다.' : '메뉴를 등록했습니다.');
|
||||
}
|
||||
navigator.pop(true);
|
||||
}
|
||||
},
|
||||
child: Text(isEdit ? '저장' : '등록'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: SingleChildScrollView(
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: saving,
|
||||
builder: (dialogContext, isSaving, __) {
|
||||
final theme = ShadTheme.of(dialogContext);
|
||||
final materialTheme = Theme.of(dialogContext);
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -606,7 +581,7 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
|
||||
: target.menuName;
|
||||
return Text(label);
|
||||
},
|
||||
onChanged: saving.value
|
||||
onChanged: isSaving
|
||||
? null
|
||||
: (next) => parentNotifier.value = next,
|
||||
options: [
|
||||
@@ -670,7 +645,7 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
|
||||
children: [
|
||||
ShadSwitch(
|
||||
value: value,
|
||||
onChanged: saving.value
|
||||
onChanged: isSaving
|
||||
? null
|
||||
: (next) => isActiveNotifier.value = next,
|
||||
),
|
||||
@@ -686,7 +661,7 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
|
||||
label: '비고',
|
||||
child: ShadTextarea(controller: noteController),
|
||||
),
|
||||
if (isEdit) ...[
|
||||
if (existingMenu != null) ...[
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'생성일시: ${_formatDateTime(existingMenu.createdAt)}',
|
||||
@@ -700,12 +675,10 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
codeController.dispose();
|
||||
@@ -722,24 +695,28 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(MenuItem menu) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
final confirmed = await SuperportDialog.show<bool>(
|
||||
context: context,
|
||||
dialog: SuperportDialog(
|
||||
title: '메뉴 삭제',
|
||||
description: '"${menu.menuName}" 메뉴를 삭제하시겠습니까?',
|
||||
secondaryAction: Builder(
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('메뉴 삭제'),
|
||||
content: Text('"${menu.menuName}" 메뉴를 삭제하시겠습니까?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
return ShadButton.ghost(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: const Text('삭제'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
primaryAction: Builder(
|
||||
builder: (dialogContext) {
|
||||
return ShadButton.destructive(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: const Text('삭제'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && menu.id != null) {
|
||||
|
||||
@@ -11,6 +11,8 @@ import '../../domain/repositories/product_repository.dart';
|
||||
enum ProductStatusFilter { all, activeOnly, inactiveOnly }
|
||||
|
||||
class ProductController extends ChangeNotifier {
|
||||
static const int defaultPageSize = 20;
|
||||
|
||||
ProductController({
|
||||
required ProductRepository productRepository,
|
||||
required VendorRepository vendorRepository,
|
||||
@@ -31,6 +33,7 @@ class ProductController extends ChangeNotifier {
|
||||
int? _vendorFilter;
|
||||
int? _uomFilter;
|
||||
ProductStatusFilter _statusFilter = ProductStatusFilter.all;
|
||||
int _pageSize = defaultPageSize;
|
||||
String? _errorMessage;
|
||||
|
||||
List<Vendor> _vendorOptions = const [];
|
||||
@@ -44,6 +47,7 @@ class ProductController extends ChangeNotifier {
|
||||
int? get vendorFilter => _vendorFilter;
|
||||
int? get uomFilter => _uomFilter;
|
||||
ProductStatusFilter get statusFilter => _statusFilter;
|
||||
int get pageSize => _pageSize;
|
||||
String? get errorMessage => _errorMessage;
|
||||
List<Vendor> get vendorOptions => _vendorOptions;
|
||||
List<Uom> get uomOptions => _uomOptions;
|
||||
@@ -60,13 +64,16 @@ class ProductController extends ChangeNotifier {
|
||||
};
|
||||
final response = await _productRepository.list(
|
||||
page: page,
|
||||
pageSize: _result?.pageSize ?? 20,
|
||||
pageSize: _pageSize,
|
||||
query: _query.isEmpty ? null : _query,
|
||||
vendorId: _vendorFilter,
|
||||
uomId: _uomFilter,
|
||||
isActive: isActive,
|
||||
);
|
||||
_result = response;
|
||||
if (response.pageSize > 0 && response.pageSize != _pageSize) {
|
||||
_pageSize = response.pageSize;
|
||||
}
|
||||
} catch (e) {
|
||||
_errorMessage = e.toString();
|
||||
} finally {
|
||||
@@ -92,25 +99,45 @@ class ProductController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void updateQuery(String value) {
|
||||
if (_query == value) {
|
||||
return;
|
||||
}
|
||||
_query = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateVendorFilter(int? vendorId) {
|
||||
if (_vendorFilter == vendorId) {
|
||||
return;
|
||||
}
|
||||
_vendorFilter = vendorId;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateUomFilter(int? uomId) {
|
||||
if (_uomFilter == uomId) {
|
||||
return;
|
||||
}
|
||||
_uomFilter = uomId;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateStatusFilter(ProductStatusFilter filter) {
|
||||
if (_statusFilter == filter) {
|
||||
return;
|
||||
}
|
||||
_statusFilter = filter;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updatePageSize(int size) {
|
||||
if (size <= 0 || _pageSize == size) {
|
||||
return;
|
||||
}
|
||||
_pageSize = size;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<Product?> create(ProductInput input) async {
|
||||
_setSubmitting(true);
|
||||
try {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import 'package:superport_v2/core/constants/app_sections.dart';
|
||||
import 'package:superport_v2/widgets/app_layout.dart';
|
||||
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
||||
import 'package:superport_v2/widgets/components/superport_dialog.dart';
|
||||
import 'package:superport_v2/widgets/components/superport_table.dart';
|
||||
|
||||
import '../../../../../core/config/environment.dart';
|
||||
import '../../../../../widgets/spec_page.dart';
|
||||
@@ -17,7 +20,9 @@ import '../../domain/repositories/product_repository.dart';
|
||||
import '../controllers/product_controller.dart';
|
||||
|
||||
class ProductPage extends StatelessWidget {
|
||||
const ProductPage({super.key});
|
||||
const ProductPage({super.key, required this.routeUri});
|
||||
|
||||
final Uri routeUri;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -65,12 +70,14 @@ class ProductPage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return const _ProductEnabledPage();
|
||||
return _ProductEnabledPage(routeUri: routeUri);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProductEnabledPage extends StatefulWidget {
|
||||
const _ProductEnabledPage();
|
||||
const _ProductEnabledPage({required this.routeUri});
|
||||
|
||||
final Uri routeUri;
|
||||
|
||||
@override
|
||||
State<_ProductEnabledPage> createState() => _ProductEnabledPageState();
|
||||
@@ -83,6 +90,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm');
|
||||
bool _lookupsLoaded = false;
|
||||
String? _lastError;
|
||||
String? _lastRouteSignature;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -92,12 +100,20 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
vendorRepository: GetIt.I<VendorRepository>(),
|
||||
uomRepository: GetIt.I<UomRepository>(),
|
||||
)..addListener(_handleControllerUpdate);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
await Future.wait([_controller.loadLookups(), _controller.fetch()]);
|
||||
setState(() {
|
||||
_lookupsLoaded = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_maybeApplyRoute();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _ProductEnabledPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.routeUri != oldWidget.routeUri) {
|
||||
_maybeApplyRoute();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleControllerUpdate() {
|
||||
@@ -138,7 +154,8 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
? false
|
||||
: (result.page * result.pageSize) < result.total;
|
||||
|
||||
final showReset = _searchController.text.isNotEmpty ||
|
||||
final showReset =
|
||||
_searchController.text.isNotEmpty ||
|
||||
_controller.vendorFilter != null ||
|
||||
_controller.uomFilter != null ||
|
||||
_controller.statusFilter != ProductStatusFilter.all;
|
||||
@@ -161,6 +178,29 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
),
|
||||
],
|
||||
toolbar: FilterBar(
|
||||
actions: [
|
||||
ShadButton.outline(
|
||||
onPressed: _controller.isLoading ? null : _applyFilters,
|
||||
child: const Text('검색 적용'),
|
||||
),
|
||||
if (showReset)
|
||||
ShadButton.ghost(
|
||||
onPressed: _controller.isLoading
|
||||
? null
|
||||
: () {
|
||||
_searchController.clear();
|
||||
_searchFocus.requestFocus();
|
||||
_updateRoute(
|
||||
page: 1,
|
||||
queryOverride: '',
|
||||
vendorOverride: null,
|
||||
uomOverride: null,
|
||||
statusOverride: ProductStatusFilter.all,
|
||||
);
|
||||
},
|
||||
child: const Text('초기화'),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 260,
|
||||
@@ -184,16 +224,15 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
}
|
||||
final vendor = _controller.vendorOptions.firstWhere(
|
||||
(v) => v.id == value,
|
||||
orElse: () => Vendor(id: value, vendorCode: '', vendorName: ''),
|
||||
orElse: () =>
|
||||
Vendor(id: value, vendorCode: '', vendorName: ''),
|
||||
);
|
||||
return Text(vendor.vendorName);
|
||||
},
|
||||
onChanged: (value) => _controller.updateVendorFilter(value),
|
||||
onChanged: (value) =>
|
||||
_updateRoute(page: 1, vendorOverride: value),
|
||||
options: [
|
||||
const ShadOption<int?>(
|
||||
value: null,
|
||||
child: Text('제조사 전체'),
|
||||
),
|
||||
const ShadOption<int?>(value: null, child: Text('제조사 전체')),
|
||||
..._controller.vendorOptions.map(
|
||||
(vendor) => ShadOption<int?>(
|
||||
value: vendor.id,
|
||||
@@ -219,12 +258,10 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
);
|
||||
return Text(uom.uomName);
|
||||
},
|
||||
onChanged: (value) => _controller.updateUomFilter(value),
|
||||
onChanged: (value) =>
|
||||
_updateRoute(page: 1, uomOverride: value),
|
||||
options: [
|
||||
const ShadOption<int?>(
|
||||
value: null,
|
||||
child: Text('단위 전체'),
|
||||
),
|
||||
const ShadOption<int?>(value: null, child: Text('단위 전체')),
|
||||
..._controller.uomOptions.map(
|
||||
(uom) => ShadOption<int?>(
|
||||
value: uom.id,
|
||||
@@ -243,7 +280,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
Text(_statusLabel(filter)),
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
_controller.updateStatusFilter(value);
|
||||
_updateRoute(page: 1, statusOverride: value);
|
||||
},
|
||||
options: ProductStatusFilter.values
|
||||
.map(
|
||||
@@ -255,27 +292,6 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
ShadButton.outline(
|
||||
onPressed: _controller.isLoading ? null : _applyFilters,
|
||||
child: const Text('검색 적용'),
|
||||
),
|
||||
if (showReset)
|
||||
ShadButton.ghost(
|
||||
onPressed: _controller.isLoading
|
||||
? null
|
||||
: () {
|
||||
_searchController.clear();
|
||||
_searchFocus.requestFocus();
|
||||
_controller.updateQuery('');
|
||||
_controller.updateVendorFilter(null);
|
||||
_controller.updateUomFilter(null);
|
||||
_controller.updateStatusFilter(
|
||||
ProductStatusFilter.all,
|
||||
);
|
||||
_controller.fetch(page: 1);
|
||||
},
|
||||
child: const Text('초기화'),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ShadCard(
|
||||
@@ -299,7 +315,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isLoading || currentPage <= 1
|
||||
? null
|
||||
: () => _controller.fetch(page: currentPage - 1),
|
||||
: () => _goToPage(currentPage - 1),
|
||||
child: const Text('이전'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -307,7 +323,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isLoading || !hasNext
|
||||
? null
|
||||
: () => _controller.fetch(page: currentPage + 1),
|
||||
: () => _goToPage(currentPage + 1),
|
||||
child: const Text('다음'),
|
||||
),
|
||||
],
|
||||
@@ -334,9 +350,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
? null
|
||||
: (product) =>
|
||||
_openProductForm(context, product: product),
|
||||
onDelete: _controller.isSubmitting
|
||||
? null
|
||||
: _confirmDelete,
|
||||
onDelete: _controller.isSubmitting ? null : _confirmDelete,
|
||||
onRestore: _controller.isSubmitting
|
||||
? null
|
||||
: _restoreProduct,
|
||||
@@ -348,8 +362,9 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
}
|
||||
|
||||
void _applyFilters() {
|
||||
_controller.updateQuery(_searchController.text.trim());
|
||||
_controller.fetch(page: 1);
|
||||
final keyword = _searchController.text.trim();
|
||||
_controller.updateQuery(keyword);
|
||||
_updateRoute(page: 1, queryOverride: keyword);
|
||||
}
|
||||
|
||||
String _statusLabel(ProductStatusFilter filter) {
|
||||
@@ -363,6 +378,126 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
}
|
||||
}
|
||||
|
||||
void _maybeApplyRoute() {
|
||||
final signature = widget.routeUri.toString();
|
||||
if (_lastRouteSignature == signature) {
|
||||
return;
|
||||
}
|
||||
_lastRouteSignature = signature;
|
||||
_applyRouteParameters();
|
||||
}
|
||||
|
||||
void _applyRouteParameters() {
|
||||
final params = widget.routeUri.queryParameters;
|
||||
final query = params['q'] ?? '';
|
||||
final vendorParam = int.tryParse(params['vendor'] ?? '');
|
||||
final uomParam = int.tryParse(params['uom'] ?? '');
|
||||
final status = _statusFromParam(params['status']);
|
||||
final pageSizeParam = int.tryParse(params['page_size'] ?? '');
|
||||
final pageParam = int.tryParse(params['page'] ?? '');
|
||||
|
||||
_searchController.text = query;
|
||||
_controller.updateQuery(query);
|
||||
_controller.updateVendorFilter(vendorParam);
|
||||
_controller.updateUomFilter(uomParam);
|
||||
_controller.updateStatusFilter(status);
|
||||
if (pageSizeParam != null && pageSizeParam > 0) {
|
||||
_controller.updatePageSize(pageSizeParam);
|
||||
}
|
||||
|
||||
final page = pageParam != null && pageParam > 0 ? pageParam : 1;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
setState(() {
|
||||
_lookupsLoaded = false;
|
||||
});
|
||||
await Future.wait([
|
||||
_controller.loadLookups(),
|
||||
_controller.fetch(page: page),
|
||||
]);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_lookupsLoaded = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _goToPage(int page) {
|
||||
if (page < 1) {
|
||||
page = 1;
|
||||
}
|
||||
_updateRoute(page: page);
|
||||
}
|
||||
|
||||
void _updateRoute({
|
||||
required int page,
|
||||
String? queryOverride,
|
||||
int? vendorOverride,
|
||||
int? uomOverride,
|
||||
ProductStatusFilter? statusOverride,
|
||||
int? pageSizeOverride,
|
||||
}) {
|
||||
final query = queryOverride ?? _controller.query;
|
||||
final vendor = vendorOverride ?? _controller.vendorFilter;
|
||||
final uom = uomOverride ?? _controller.uomFilter;
|
||||
final status = statusOverride ?? _controller.statusFilter;
|
||||
final pageSize = pageSizeOverride ?? _controller.pageSize;
|
||||
|
||||
final params = <String, String>{};
|
||||
if (query.isNotEmpty) {
|
||||
params['q'] = query;
|
||||
}
|
||||
if (vendor != null) {
|
||||
params['vendor'] = vendor.toString();
|
||||
}
|
||||
if (uom != null) {
|
||||
params['uom'] = uom.toString();
|
||||
}
|
||||
final statusParam = _encodeStatus(status);
|
||||
if (statusParam != null) {
|
||||
params['status'] = statusParam;
|
||||
}
|
||||
if (page > 1) {
|
||||
params['page'] = page.toString();
|
||||
}
|
||||
if (pageSize > 0 && pageSize != ProductController.defaultPageSize) {
|
||||
params['page_size'] = pageSize.toString();
|
||||
}
|
||||
|
||||
final uri = Uri(
|
||||
path: widget.routeUri.path,
|
||||
queryParameters: params.isEmpty ? null : params,
|
||||
);
|
||||
final newLocation = uri.toString();
|
||||
if (widget.routeUri.toString() == newLocation) {
|
||||
_controller.fetch(page: page);
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).go(newLocation);
|
||||
}
|
||||
|
||||
ProductStatusFilter _statusFromParam(String? value) {
|
||||
switch (value) {
|
||||
case 'active':
|
||||
return ProductStatusFilter.activeOnly;
|
||||
case 'inactive':
|
||||
return ProductStatusFilter.inactiveOnly;
|
||||
default:
|
||||
return ProductStatusFilter.all;
|
||||
}
|
||||
}
|
||||
|
||||
String? _encodeStatus(ProductStatusFilter filter) {
|
||||
switch (filter) {
|
||||
case ProductStatusFilter.all:
|
||||
return null;
|
||||
case ProductStatusFilter.activeOnly:
|
||||
return 'active';
|
||||
case ProductStatusFilter.inactiveOnly:
|
||||
return 'inactive';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openProductForm(
|
||||
BuildContext context, {
|
||||
Product? product,
|
||||
@@ -398,38 +533,15 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
final vendorError = ValueNotifier<String?>(null);
|
||||
final uomError = ValueNotifier<String?>(null);
|
||||
|
||||
await showDialog<bool>(
|
||||
await SuperportDialog.show<bool>(
|
||||
context: parentContext,
|
||||
builder: (dialogContext) {
|
||||
final theme = ShadTheme.of(dialogContext);
|
||||
final materialTheme = Theme.of(dialogContext);
|
||||
final navigator = Navigator.of(dialogContext);
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.all(24),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: ShadCard(
|
||||
title: Text(
|
||||
isEdit ? '제품 수정' : '제품 등록',
|
||||
style: theme.textTheme.h3,
|
||||
),
|
||||
description: Text(
|
||||
'제품 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
footer: ValueListenableBuilder<bool>(
|
||||
dialog: SuperportDialog(
|
||||
title: isEdit ? '제품 수정' : '제품 등록',
|
||||
description: '제품 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.',
|
||||
primaryAction: ValueListenableBuilder<bool>(
|
||||
valueListenable: saving,
|
||||
builder: (_, isSaving, __) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ShadButton.ghost(
|
||||
onPressed: isSaving ? null : () => navigator.pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ShadButton(
|
||||
builder: (context, isSaving, _) {
|
||||
return ShadButton(
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () async {
|
||||
@@ -439,18 +551,12 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
final vendorId = vendorNotifier.value;
|
||||
final uomId = uomNotifier.value;
|
||||
|
||||
codeError.value = code.isEmpty
|
||||
? '제품코드를 입력하세요.'
|
||||
: null;
|
||||
nameError.value = name.isEmpty
|
||||
? '제품명을 입력하세요.'
|
||||
: null;
|
||||
codeError.value = code.isEmpty ? '제품코드를 입력하세요.' : null;
|
||||
nameError.value = name.isEmpty ? '제품명을 입력하세요.' : null;
|
||||
vendorError.value = vendorId == null
|
||||
? '제조사를 선택하세요.'
|
||||
: null;
|
||||
uomError.value = uomId == null
|
||||
? '단위를 선택하세요.'
|
||||
: null;
|
||||
uomError.value = uomId == null ? '단위를 선택하세요.' : null;
|
||||
|
||||
if (codeError.value != null ||
|
||||
nameError.value != null ||
|
||||
@@ -468,33 +574,40 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
isActive: isActiveNotifier.value,
|
||||
note: note.isEmpty ? null : note,
|
||||
);
|
||||
final navigator = Navigator.of(context);
|
||||
final response = isEdit
|
||||
? await _controller.update(
|
||||
productId!,
|
||||
input,
|
||||
)
|
||||
? await _controller.update(productId!, input)
|
||||
: await _controller.create(input);
|
||||
saving.value = false;
|
||||
if (response != null) {
|
||||
if (response != null && mounted) {
|
||||
if (!navigator.mounted) {
|
||||
return;
|
||||
}
|
||||
if (mounted) {
|
||||
_showSnack(
|
||||
isEdit ? '제품을 수정했습니다.' : '제품을 등록했습니다.',
|
||||
);
|
||||
}
|
||||
_showSnack(isEdit ? '제품을 수정했습니다.' : '제품을 등록했습니다.');
|
||||
navigator.pop(true);
|
||||
}
|
||||
},
|
||||
child: Text(isEdit ? '저장' : '등록'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
secondaryAction: ValueListenableBuilder<bool>(
|
||||
valueListenable: saving,
|
||||
builder: (context, isSaving, _) {
|
||||
return ShadButton.ghost(
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () => Navigator.of(context).pop(false),
|
||||
child: const Text('취소'),
|
||||
);
|
||||
},
|
||||
),
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: saving,
|
||||
builder: (context, isSaving, _) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final materialTheme = Theme.of(context);
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -614,8 +727,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
child: Text(
|
||||
errorText,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color:
|
||||
materialTheme.colorScheme.error,
|
||||
color: materialTheme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -674,8 +786,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
child: Text(
|
||||
errorText,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color:
|
||||
materialTheme.colorScheme.error,
|
||||
color: materialTheme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -726,11 +837,10 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
codeController.dispose();
|
||||
@@ -747,24 +857,20 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(Product product) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
final bool? confirmed = await SuperportDialog.show<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('제품 삭제'),
|
||||
content: Text('"${product.productName}" 제품을 삭제하시겠습니까?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
dialog: SuperportDialog(
|
||||
title: '제품 삭제',
|
||||
description: '"${product.productName}" 제품을 삭제하시겠습니까?',
|
||||
primaryAction: ShadButton.destructive(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('삭제'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
secondaryAction: ShadButton.ghost(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && product.id != null) {
|
||||
@@ -813,43 +919,47 @@ class _ProductTable extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final header = [
|
||||
'ID',
|
||||
'제품코드',
|
||||
'제품명',
|
||||
'제조사',
|
||||
'단위',
|
||||
'사용',
|
||||
'삭제',
|
||||
'비고',
|
||||
'변경일시',
|
||||
'동작',
|
||||
].map((text) => ShadTableCell.header(child: Text(text))).toList();
|
||||
final columns = const [
|
||||
Text('ID'),
|
||||
Text('제품코드'),
|
||||
Text('제품명'),
|
||||
Text('제조사'),
|
||||
Text('단위'),
|
||||
Text('사용'),
|
||||
Text('삭제'),
|
||||
Text('비고'),
|
||||
Text('변경일시'),
|
||||
Text('동작'),
|
||||
];
|
||||
|
||||
final rows = products.map((product) {
|
||||
return [
|
||||
product.id?.toString() ?? '-',
|
||||
product.productCode,
|
||||
product.productName,
|
||||
product.vendor?.vendorName ?? '-',
|
||||
product.uom?.uomName ?? '-',
|
||||
product.isActive ? 'Y' : 'N',
|
||||
product.isDeleted ? 'Y' : '-',
|
||||
product.note?.isEmpty ?? true ? '-' : product.note!,
|
||||
final cells = <Widget>[
|
||||
Text(product.id?.toString() ?? '-'),
|
||||
Text(product.productCode),
|
||||
Text(product.productName),
|
||||
Text(product.vendor?.vendorName ?? '-'),
|
||||
Text(product.uom?.uomName ?? '-'),
|
||||
Text(product.isActive ? 'Y' : 'N'),
|
||||
Text(product.isDeleted ? 'Y' : '-'),
|
||||
Text(product.note?.isEmpty ?? true ? '-' : product.note!),
|
||||
Text(
|
||||
product.updatedAt == null
|
||||
? '-'
|
||||
: dateFormat.format(product.updatedAt!.toLocal()),
|
||||
].map((text) => ShadTableCell(child: Text(text))).toList()..add(
|
||||
),
|
||||
];
|
||||
|
||||
cells.add(
|
||||
ShadTableCell(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
alignment: Alignment.centerRight,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
ShadButton.ghost(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: onEdit == null ? null : () => onEdit!(product),
|
||||
child: const Icon(LucideIcons.pencil, size: 16),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
product.isDeleted
|
||||
? ShadButton.ghost(
|
||||
size: ShadButtonSize.sm,
|
||||
@@ -869,17 +979,18 @@ class _ProductTable extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return cells;
|
||||
}).toList();
|
||||
|
||||
return SizedBox(
|
||||
height: 56.0 * (products.length + 1),
|
||||
child: ShadTable.list(
|
||||
header: header,
|
||||
children: rows,
|
||||
return SuperportTable(
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
rowHeight: 56,
|
||||
maxHeight: 520,
|
||||
columnSpanExtent: (index) => index == 9
|
||||
? const FixedTableSpanExtent(160)
|
||||
: const FixedTableSpanExtent(140),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ enum VendorStatusFilter { all, activeOnly, inactiveOnly }
|
||||
/// - 목록/검색/필터/페이지 상태를 관리한다.
|
||||
/// - 생성/수정/삭제/복구 요청을 래핑하여 UI에 알린다.
|
||||
class VendorController extends ChangeNotifier {
|
||||
static const int defaultPageSize = 20;
|
||||
|
||||
VendorController({required VendorRepository repository})
|
||||
: _repository = repository;
|
||||
|
||||
@@ -21,6 +23,7 @@ class VendorController extends ChangeNotifier {
|
||||
bool _isSubmitting = false;
|
||||
String _query = '';
|
||||
VendorStatusFilter _statusFilter = VendorStatusFilter.all;
|
||||
int _pageSize = defaultPageSize;
|
||||
String? _errorMessage;
|
||||
|
||||
PaginatedResult<Vendor>? get result => _result;
|
||||
@@ -28,6 +31,7 @@ class VendorController extends ChangeNotifier {
|
||||
bool get isSubmitting => _isSubmitting;
|
||||
String get query => _query;
|
||||
VendorStatusFilter get statusFilter => _statusFilter;
|
||||
int get pageSize => _pageSize;
|
||||
String? get errorMessage => _errorMessage;
|
||||
|
||||
/// 목록 갱신
|
||||
@@ -43,11 +47,14 @@ class VendorController extends ChangeNotifier {
|
||||
};
|
||||
final response = await _repository.list(
|
||||
page: page,
|
||||
pageSize: _result?.pageSize ?? 20,
|
||||
pageSize: _pageSize,
|
||||
query: _query.isEmpty ? null : _query,
|
||||
isActive: isActive,
|
||||
);
|
||||
_result = response;
|
||||
if (response.pageSize > 0 && response.pageSize != _pageSize) {
|
||||
_pageSize = response.pageSize;
|
||||
}
|
||||
} catch (e) {
|
||||
_errorMessage = e.toString();
|
||||
} finally {
|
||||
@@ -57,15 +64,29 @@ class VendorController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void updateQuery(String value) {
|
||||
if (_query == value) {
|
||||
return;
|
||||
}
|
||||
_query = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateStatusFilter(VendorStatusFilter filter) {
|
||||
if (_statusFilter == filter) {
|
||||
return;
|
||||
}
|
||||
_statusFilter = filter;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updatePageSize(int size) {
|
||||
if (size <= 0 || _pageSize == size) {
|
||||
return;
|
||||
}
|
||||
_pageSize = size;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 신규 등록
|
||||
Future<Vendor?> create(VendorInput input) async {
|
||||
_setSubmitting(true);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import 'package:superport_v2/core/constants/app_sections.dart';
|
||||
import 'package:superport_v2/widgets/app_layout.dart';
|
||||
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
||||
import 'package:superport_v2/widgets/components/superport_dialog.dart';
|
||||
import 'package:superport_v2/widgets/components/superport_table.dart';
|
||||
|
||||
import '../../../../../core/config/environment.dart';
|
||||
import '../../../../../widgets/spec_page.dart';
|
||||
@@ -13,7 +16,9 @@ import '../../../vendor/domain/repositories/vendor_repository.dart';
|
||||
import '../controllers/vendor_controller.dart';
|
||||
|
||||
class VendorPage extends StatelessWidget {
|
||||
const VendorPage({super.key});
|
||||
const VendorPage({super.key, required this.routeUri});
|
||||
|
||||
final Uri routeUri;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -58,12 +63,14 @@ class VendorPage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return const _VendorEnabledPage();
|
||||
return _VendorEnabledPage(routeUri: routeUri);
|
||||
}
|
||||
}
|
||||
|
||||
class _VendorEnabledPage extends StatefulWidget {
|
||||
const _VendorEnabledPage();
|
||||
const _VendorEnabledPage({required this.routeUri});
|
||||
|
||||
final Uri routeUri;
|
||||
|
||||
@override
|
||||
State<_VendorEnabledPage> createState() => _VendorEnabledPageState();
|
||||
@@ -75,13 +82,22 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
||||
final FocusNode _searchFocusNode = FocusNode();
|
||||
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm');
|
||||
String? _lastError;
|
||||
bool _routeApplied = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = VendorController(repository: GetIt.I<VendorRepository>());
|
||||
_controller.addListener(_onControllerChanged);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _controller.fetch());
|
||||
_controller = VendorController(repository: GetIt.I<VendorRepository>())
|
||||
..addListener(_onControllerChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_routeApplied) {
|
||||
_routeApplied = true;
|
||||
_applyRouteParameters();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -140,6 +156,32 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
||||
),
|
||||
],
|
||||
toolbar: FilterBar(
|
||||
actions: [
|
||||
ShadButton.outline(
|
||||
onPressed: _controller.isLoading ? null : _applyFilters,
|
||||
child: const Text('검색 적용'),
|
||||
),
|
||||
if (_searchController.text.isNotEmpty ||
|
||||
_controller.statusFilter != VendorStatusFilter.all)
|
||||
ShadButton.ghost(
|
||||
onPressed: _controller.isLoading
|
||||
? null
|
||||
: () {
|
||||
_searchController.clear();
|
||||
_searchFocusNode.requestFocus();
|
||||
_controller.updateQuery('');
|
||||
_controller.updateStatusFilter(
|
||||
VendorStatusFilter.all,
|
||||
);
|
||||
_updateRoute(
|
||||
page: 1,
|
||||
queryOverride: '',
|
||||
statusOverride: VendorStatusFilter.all,
|
||||
);
|
||||
},
|
||||
child: const Text('초기화'),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 280,
|
||||
@@ -159,10 +201,9 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
||||
selectedOptionBuilder: (context, value) =>
|
||||
Text(_statusLabel(value)),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
if (value == null) return;
|
||||
_controller.updateStatusFilter(value);
|
||||
_controller.fetch(page: 1);
|
||||
}
|
||||
_updateRoute(page: 1, statusOverride: value);
|
||||
},
|
||||
options: VendorStatusFilter.values
|
||||
.map(
|
||||
@@ -174,26 +215,6 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
ShadButton.outline(
|
||||
onPressed: _controller.isLoading ? null : _applyFilters,
|
||||
child: const Text('검색 적용'),
|
||||
),
|
||||
if (_searchController.text.isNotEmpty ||
|
||||
_controller.statusFilter != VendorStatusFilter.all)
|
||||
ShadButton.ghost(
|
||||
onPressed: _controller.isLoading
|
||||
? null
|
||||
: () {
|
||||
_searchController.clear();
|
||||
_searchFocusNode.requestFocus();
|
||||
_controller.updateQuery('');
|
||||
_controller.updateStatusFilter(
|
||||
VendorStatusFilter.all,
|
||||
);
|
||||
_controller.fetch(page: 1);
|
||||
},
|
||||
child: const Text('초기화'),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ShadCard(
|
||||
@@ -217,7 +238,7 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isLoading || currentPage <= 1
|
||||
? null
|
||||
: () => _controller.fetch(page: currentPage - 1),
|
||||
: () => _goToPage(currentPage - 1),
|
||||
child: const Text('이전'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -225,7 +246,7 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isLoading || !hasNext
|
||||
? null
|
||||
: () => _controller.fetch(page: currentPage + 1),
|
||||
: () => _goToPage(currentPage + 1),
|
||||
child: const Text('다음'),
|
||||
),
|
||||
],
|
||||
@@ -249,14 +270,9 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
||||
vendors: vendors,
|
||||
onEdit: _controller.isSubmitting
|
||||
? null
|
||||
: (vendor) =>
|
||||
_openVendorForm(context, vendor: vendor),
|
||||
onDelete: _controller.isSubmitting
|
||||
? null
|
||||
: _confirmDelete,
|
||||
onRestore: _controller.isSubmitting
|
||||
? null
|
||||
: _restoreVendor,
|
||||
: (vendor) => _openVendorForm(context, vendor: vendor),
|
||||
onDelete: _controller.isSubmitting ? null : _confirmDelete,
|
||||
onRestore: _controller.isSubmitting ? null : _restoreVendor,
|
||||
dateFormat: _dateFormat,
|
||||
),
|
||||
),
|
||||
@@ -266,8 +282,9 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
||||
}
|
||||
|
||||
void _applyFilters() {
|
||||
_controller.updateQuery(_searchController.text.trim());
|
||||
_controller.fetch(page: 1);
|
||||
final keyword = _searchController.text.trim();
|
||||
_controller.updateQuery(keyword);
|
||||
_updateRoute(page: 1, queryOverride: keyword);
|
||||
}
|
||||
|
||||
String _statusLabel(VendorStatusFilter filter) {
|
||||
@@ -281,6 +298,90 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
||||
}
|
||||
}
|
||||
|
||||
void _applyRouteParameters() {
|
||||
final params = widget.routeUri.queryParameters;
|
||||
final query = params['q'] ?? '';
|
||||
final status = _statusFromParam(params['status']);
|
||||
final pageSizeParam = int.tryParse(params['page_size'] ?? '');
|
||||
final pageParam = int.tryParse(params['page'] ?? '');
|
||||
|
||||
_searchController.text = query;
|
||||
_controller.updateQuery(query);
|
||||
_controller.updateStatusFilter(status);
|
||||
if (pageSizeParam != null && pageSizeParam > 0) {
|
||||
_controller.updatePageSize(pageSizeParam);
|
||||
}
|
||||
|
||||
final page = pageParam != null && pageParam > 0 ? pageParam : 1;
|
||||
_controller.fetch(page: page);
|
||||
}
|
||||
|
||||
void _goToPage(int page) {
|
||||
if (page < 1) {
|
||||
page = 1;
|
||||
}
|
||||
_updateRoute(page: page);
|
||||
}
|
||||
|
||||
void _updateRoute({
|
||||
required int page,
|
||||
String? queryOverride,
|
||||
VendorStatusFilter? statusOverride,
|
||||
int? pageSizeOverride,
|
||||
}) {
|
||||
final query = queryOverride ?? _controller.query;
|
||||
final status = statusOverride ?? _controller.statusFilter;
|
||||
final pageSize = pageSizeOverride ?? _controller.pageSize;
|
||||
|
||||
final params = <String, String>{};
|
||||
if (query.isNotEmpty) {
|
||||
params['q'] = query;
|
||||
}
|
||||
final statusParam = _encodeStatus(status);
|
||||
if (statusParam != null) {
|
||||
params['status'] = statusParam;
|
||||
}
|
||||
if (page > 1) {
|
||||
params['page'] = page.toString();
|
||||
}
|
||||
if (pageSize > 0 && pageSize != VendorController.defaultPageSize) {
|
||||
params['page_size'] = pageSize.toString();
|
||||
}
|
||||
|
||||
final uri = Uri(
|
||||
path: widget.routeUri.path,
|
||||
queryParameters: params.isEmpty ? null : params,
|
||||
);
|
||||
final newLocation = uri.toString();
|
||||
if (widget.routeUri.toString() == newLocation) {
|
||||
_controller.fetch(page: page);
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).go(newLocation);
|
||||
}
|
||||
|
||||
VendorStatusFilter _statusFromParam(String? value) {
|
||||
switch (value) {
|
||||
case 'active':
|
||||
return VendorStatusFilter.activeOnly;
|
||||
case 'inactive':
|
||||
return VendorStatusFilter.inactiveOnly;
|
||||
default:
|
||||
return VendorStatusFilter.all;
|
||||
}
|
||||
}
|
||||
|
||||
String? _encodeStatus(VendorStatusFilter filter) {
|
||||
switch (filter) {
|
||||
case VendorStatusFilter.all:
|
||||
return null;
|
||||
case VendorStatusFilter.activeOnly:
|
||||
return 'active';
|
||||
case VendorStatusFilter.inactiveOnly:
|
||||
return 'inactive';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openVendorForm(BuildContext context, {Vendor? vendor}) async {
|
||||
final existingVendor = vendor;
|
||||
final isEdit = existingVendor != null;
|
||||
@@ -306,38 +407,16 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
||||
final codeError = ValueNotifier<String?>(null);
|
||||
final nameError = ValueNotifier<String?>(null);
|
||||
|
||||
await showDialog<bool>(
|
||||
await SuperportDialog.show<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
final theme = ShadTheme.of(dialogContext);
|
||||
final materialTheme = Theme.of(dialogContext);
|
||||
final navigator = Navigator.of(dialogContext);
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.all(24),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ConstrainedBox(
|
||||
dialog: SuperportDialog(
|
||||
title: isEdit ? '벤더 수정' : '벤더 등록',
|
||||
description: '벤더 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.',
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: ShadCard(
|
||||
title: Text(
|
||||
isEdit ? '벤더 수정' : '벤더 등록',
|
||||
style: theme.textTheme.h3,
|
||||
),
|
||||
description: Text(
|
||||
'벤더 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
footer: ValueListenableBuilder<bool>(
|
||||
primaryAction: ValueListenableBuilder<bool>(
|
||||
valueListenable: saving,
|
||||
builder: (_, isSaving, __) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ShadButton.ghost(
|
||||
onPressed: isSaving ? null : () => navigator.pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ShadButton(
|
||||
builder: (context, isSaving, _) {
|
||||
return ShadButton(
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () async {
|
||||
@@ -345,15 +424,10 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
||||
final name = nameController.text.trim();
|
||||
final note = noteController.text.trim();
|
||||
|
||||
codeError.value = code.isEmpty
|
||||
? '벤더코드를 입력하세요.'
|
||||
: null;
|
||||
nameError.value = name.isEmpty
|
||||
? '벤더명을 입력하세요.'
|
||||
: null;
|
||||
codeError.value = code.isEmpty ? '벤더코드를 입력하세요.' : null;
|
||||
nameError.value = name.isEmpty ? '벤더명을 입력하세요.' : null;
|
||||
|
||||
if (codeError.value != null ||
|
||||
nameError.value != null) {
|
||||
if (codeError.value != null || nameError.value != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -364,29 +438,39 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
||||
isActive: isActiveNotifier.value,
|
||||
note: note.isEmpty ? null : note,
|
||||
);
|
||||
final navigator = Navigator.of(context);
|
||||
final response = isEdit
|
||||
? await _controller.update(vendorId!, input)
|
||||
: await _controller.create(input);
|
||||
saving.value = false;
|
||||
if (response != null) {
|
||||
if (response != null && mounted) {
|
||||
if (!navigator.mounted) {
|
||||
return;
|
||||
}
|
||||
if (mounted) {
|
||||
_showSnack(
|
||||
isEdit ? '벤더를 수정했습니다.' : '벤더를 등록했습니다.',
|
||||
);
|
||||
}
|
||||
_showSnack(isEdit ? '벤더를 수정했습니다.' : '벤더를 등록했습니다.');
|
||||
navigator.pop(true);
|
||||
}
|
||||
},
|
||||
child: Text(isEdit ? '저장' : '등록'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
child: Padding(
|
||||
secondaryAction: ValueListenableBuilder<bool>(
|
||||
valueListenable: saving,
|
||||
builder: (context, isSaving, _) {
|
||||
return ShadButton.ghost(
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () => Navigator.of(context).pop(false),
|
||||
child: const Text('취소'),
|
||||
);
|
||||
},
|
||||
),
|
||||
child: Builder(
|
||||
builder: (dialogContext) {
|
||||
final theme = ShadTheme.of(dialogContext);
|
||||
final materialTheme = Theme.of(dialogContext);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -496,11 +580,10 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
codeController.dispose();
|
||||
@@ -513,24 +596,22 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(Vendor vendor) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
final confirmed = await SuperportDialog.show<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('벤더 삭제'),
|
||||
content: Text('"${vendor.vendorName}" 벤더를 삭제하시겠습니까?'),
|
||||
dialog: SuperportDialog(
|
||||
title: '벤더 삭제',
|
||||
description: '"${vendor.vendorName}" 벤더를 삭제하시겠습니까?',
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
ShadButton.ghost(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
ShadButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('삭제'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && vendor.id != null) {
|
||||
@@ -581,39 +662,43 @@ class _VendorTable extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final header = [
|
||||
'ID',
|
||||
'벤더코드',
|
||||
'벤더명',
|
||||
'사용',
|
||||
'삭제',
|
||||
'비고',
|
||||
'변경일시',
|
||||
'동작',
|
||||
].map((text) => ShadTableCell.header(child: Text(text))).toList();
|
||||
final columns = const [
|
||||
Text('ID'),
|
||||
Text('벤더코드'),
|
||||
Text('벤더명'),
|
||||
Text('사용'),
|
||||
Text('삭제'),
|
||||
Text('비고'),
|
||||
Text('변경일시'),
|
||||
Text('동작'),
|
||||
];
|
||||
|
||||
final rows = vendors.map((vendor) {
|
||||
return [
|
||||
vendor.id?.toString() ?? '-',
|
||||
vendor.vendorCode,
|
||||
vendor.vendorName,
|
||||
vendor.isActive ? 'Y' : 'N',
|
||||
vendor.isDeleted ? 'Y' : '-',
|
||||
vendor.note?.isEmpty ?? true ? '-' : vendor.note!,
|
||||
final cells = <Widget>[
|
||||
Text(vendor.id?.toString() ?? '-'),
|
||||
Text(vendor.vendorCode),
|
||||
Text(vendor.vendorName),
|
||||
Text(vendor.isActive ? 'Y' : 'N'),
|
||||
Text(vendor.isDeleted ? 'Y' : '-'),
|
||||
Text(vendor.note?.isEmpty ?? true ? '-' : vendor.note!),
|
||||
Text(
|
||||
vendor.updatedAt == null
|
||||
? '-'
|
||||
: dateFormat.format(vendor.updatedAt!.toLocal()),
|
||||
].map((text) => ShadTableCell(child: Text(text))).toList()..add(
|
||||
),
|
||||
];
|
||||
|
||||
cells.add(
|
||||
ShadTableCell(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
alignment: Alignment.centerRight,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
ShadButton.ghost(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: onEdit == null ? null : () => onEdit!(vendor),
|
||||
child: const Icon(LucideIcons.pencil, size: 16),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
vendor.isDeleted
|
||||
? ShadButton.ghost(
|
||||
size: ShadButtonSize.sm,
|
||||
@@ -633,17 +718,18 @@ class _VendorTable extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return cells;
|
||||
}).toList();
|
||||
|
||||
return SizedBox(
|
||||
height: 56.0 * (vendors.length + 1),
|
||||
child: ShadTable.list(
|
||||
header: header,
|
||||
children: rows,
|
||||
return SuperportTable(
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
rowHeight: 56,
|
||||
maxHeight: 520,
|
||||
columnSpanExtent: (index) => index == 7
|
||||
? const FixedTableSpanExtent(160)
|
||||
: const FixedTableSpanExtent(140),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import '../../domain/repositories/warehouse_repository.dart';
|
||||
enum WarehouseStatusFilter { all, activeOnly, inactiveOnly }
|
||||
|
||||
class WarehouseController extends ChangeNotifier {
|
||||
static const int defaultPageSize = 20;
|
||||
|
||||
WarehouseController({required WarehouseRepository repository})
|
||||
: _repository = repository;
|
||||
|
||||
@@ -17,6 +19,7 @@ class WarehouseController extends ChangeNotifier {
|
||||
bool _isSubmitting = false;
|
||||
String _query = '';
|
||||
WarehouseStatusFilter _statusFilter = WarehouseStatusFilter.all;
|
||||
int _pageSize = defaultPageSize;
|
||||
String? _errorMessage;
|
||||
|
||||
PaginatedResult<Warehouse>? get result => _result;
|
||||
@@ -24,6 +27,7 @@ class WarehouseController extends ChangeNotifier {
|
||||
bool get isSubmitting => _isSubmitting;
|
||||
String get query => _query;
|
||||
WarehouseStatusFilter get statusFilter => _statusFilter;
|
||||
int get pageSize => _pageSize;
|
||||
String? get errorMessage => _errorMessage;
|
||||
|
||||
Future<void> fetch({int page = 1}) async {
|
||||
@@ -38,11 +42,14 @@ class WarehouseController extends ChangeNotifier {
|
||||
};
|
||||
final response = await _repository.list(
|
||||
page: page,
|
||||
pageSize: _result?.pageSize ?? 20,
|
||||
pageSize: _pageSize,
|
||||
query: _query.isEmpty ? null : _query,
|
||||
isActive: isActive,
|
||||
);
|
||||
_result = response;
|
||||
if (response.pageSize > 0 && response.pageSize != _pageSize) {
|
||||
_pageSize = response.pageSize;
|
||||
}
|
||||
} catch (e) {
|
||||
_errorMessage = e.toString();
|
||||
} finally {
|
||||
@@ -52,15 +59,29 @@ class WarehouseController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void updateQuery(String value) {
|
||||
if (_query == value) {
|
||||
return;
|
||||
}
|
||||
_query = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateStatusFilter(WarehouseStatusFilter filter) {
|
||||
if (_statusFilter == filter) {
|
||||
return;
|
||||
}
|
||||
_statusFilter = filter;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updatePageSize(int size) {
|
||||
if (size <= 0 || _pageSize == size) {
|
||||
return;
|
||||
}
|
||||
_pageSize = size;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<Warehouse?> create(WarehouseInput input) async {
|
||||
_setSubmitting(true);
|
||||
try {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import 'package:superport_v2/core/constants/app_sections.dart';
|
||||
import 'package:superport_v2/widgets/app_layout.dart';
|
||||
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
||||
import 'package:superport_v2/widgets/components/superport_dialog.dart';
|
||||
import 'package:superport_v2/widgets/components/superport_table.dart';
|
||||
import 'package:superport_v2/features/util/postal_search/presentation/models/postal_search_result.dart';
|
||||
import 'package:superport_v2/features/util/postal_search/presentation/widgets/postal_search_dialog.dart';
|
||||
|
||||
@@ -15,7 +18,9 @@ import '../../domain/repositories/warehouse_repository.dart';
|
||||
import '../controllers/warehouse_controller.dart';
|
||||
|
||||
class WarehousePage extends StatelessWidget {
|
||||
const WarehousePage({super.key});
|
||||
const WarehousePage({super.key, required this.routeUri});
|
||||
|
||||
final Uri routeUri;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -72,12 +77,14 @@ class WarehousePage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return const _WarehouseEnabledPage();
|
||||
return _WarehouseEnabledPage(routeUri: routeUri);
|
||||
}
|
||||
}
|
||||
|
||||
class _WarehouseEnabledPage extends StatefulWidget {
|
||||
const _WarehouseEnabledPage();
|
||||
const _WarehouseEnabledPage({required this.routeUri});
|
||||
|
||||
final Uri routeUri;
|
||||
|
||||
@override
|
||||
State<_WarehouseEnabledPage> createState() => _WarehouseEnabledPageState();
|
||||
@@ -89,6 +96,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
final FocusNode _searchFocus = FocusNode();
|
||||
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm');
|
||||
String? _lastError;
|
||||
bool _routeApplied = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -96,9 +104,15 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
_controller = WarehouseController(
|
||||
repository: GetIt.I<WarehouseRepository>(),
|
||||
)..addListener(_handleControllerUpdate);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_controller.fetch();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_routeApplied) {
|
||||
_routeApplied = true;
|
||||
_applyRouteParameters();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleControllerUpdate() {
|
||||
@@ -161,6 +175,31 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
),
|
||||
],
|
||||
toolbar: FilterBar(
|
||||
actions: [
|
||||
ShadButton.outline(
|
||||
onPressed: _controller.isLoading ? null : _applyFilters,
|
||||
child: const Text('검색 적용'),
|
||||
),
|
||||
if (showReset)
|
||||
ShadButton.ghost(
|
||||
onPressed: _controller.isLoading
|
||||
? null
|
||||
: () {
|
||||
_searchController.clear();
|
||||
_searchFocus.requestFocus();
|
||||
_controller.updateQuery('');
|
||||
_controller.updateStatusFilter(
|
||||
WarehouseStatusFilter.all,
|
||||
);
|
||||
_updateRoute(
|
||||
page: 1,
|
||||
queryOverride: '',
|
||||
statusOverride: WarehouseStatusFilter.all,
|
||||
);
|
||||
},
|
||||
child: const Text('초기화'),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 260,
|
||||
@@ -182,6 +221,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
_controller.updateStatusFilter(value);
|
||||
_updateRoute(page: 1, statusOverride: value);
|
||||
},
|
||||
options: WarehouseStatusFilter.values
|
||||
.map(
|
||||
@@ -193,25 +233,6 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
ShadButton.outline(
|
||||
onPressed: _controller.isLoading ? null : _applyFilters,
|
||||
child: const Text('검색 적용'),
|
||||
),
|
||||
if (showReset)
|
||||
ShadButton.ghost(
|
||||
onPressed: _controller.isLoading
|
||||
? null
|
||||
: () {
|
||||
_searchController.clear();
|
||||
_searchFocus.requestFocus();
|
||||
_controller.updateQuery('');
|
||||
_controller.updateStatusFilter(
|
||||
WarehouseStatusFilter.all,
|
||||
);
|
||||
_controller.fetch(page: 1);
|
||||
},
|
||||
child: const Text('초기화'),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ShadCard(
|
||||
@@ -235,7 +256,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isLoading || currentPage <= 1
|
||||
? null
|
||||
: () => _controller.fetch(page: currentPage - 1),
|
||||
: () => _goToPage(currentPage - 1),
|
||||
child: const Text('이전'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -243,7 +264,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isLoading || !hasNext
|
||||
? null
|
||||
: () => _controller.fetch(page: currentPage + 1),
|
||||
: () => _goToPage(currentPage + 1),
|
||||
child: const Text('다음'),
|
||||
),
|
||||
],
|
||||
@@ -282,8 +303,9 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
}
|
||||
|
||||
void _applyFilters() {
|
||||
_controller.updateQuery(_searchController.text.trim());
|
||||
_controller.fetch(page: 1);
|
||||
final keyword = _searchController.text.trim();
|
||||
_controller.updateQuery(keyword);
|
||||
_updateRoute(page: 1, queryOverride: keyword);
|
||||
}
|
||||
|
||||
String _statusLabel(WarehouseStatusFilter filter) {
|
||||
@@ -297,6 +319,90 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
}
|
||||
}
|
||||
|
||||
void _applyRouteParameters() {
|
||||
final params = widget.routeUri.queryParameters;
|
||||
final query = params['q'] ?? '';
|
||||
final status = _statusFromParam(params['status']);
|
||||
final pageSizeParam = int.tryParse(params['page_size'] ?? '');
|
||||
final pageParam = int.tryParse(params['page'] ?? '');
|
||||
|
||||
_searchController.text = query;
|
||||
_controller.updateQuery(query);
|
||||
_controller.updateStatusFilter(status);
|
||||
if (pageSizeParam != null && pageSizeParam > 0) {
|
||||
_controller.updatePageSize(pageSizeParam);
|
||||
}
|
||||
|
||||
final page = pageParam != null && pageParam > 0 ? pageParam : 1;
|
||||
_controller.fetch(page: page);
|
||||
}
|
||||
|
||||
void _goToPage(int page) {
|
||||
if (page < 1) {
|
||||
page = 1;
|
||||
}
|
||||
_updateRoute(page: page);
|
||||
}
|
||||
|
||||
void _updateRoute({
|
||||
required int page,
|
||||
String? queryOverride,
|
||||
WarehouseStatusFilter? statusOverride,
|
||||
int? pageSizeOverride,
|
||||
}) {
|
||||
final query = queryOverride ?? _controller.query;
|
||||
final status = statusOverride ?? _controller.statusFilter;
|
||||
final pageSize = pageSizeOverride ?? _controller.pageSize;
|
||||
|
||||
final params = <String, String>{};
|
||||
if (query.isNotEmpty) {
|
||||
params['q'] = query;
|
||||
}
|
||||
final statusParam = _encodeStatus(status);
|
||||
if (statusParam != null) {
|
||||
params['status'] = statusParam;
|
||||
}
|
||||
if (page > 1) {
|
||||
params['page'] = page.toString();
|
||||
}
|
||||
if (pageSize > 0 && pageSize != WarehouseController.defaultPageSize) {
|
||||
params['page_size'] = pageSize.toString();
|
||||
}
|
||||
|
||||
final uri = Uri(
|
||||
path: widget.routeUri.path,
|
||||
queryParameters: params.isEmpty ? null : params,
|
||||
);
|
||||
final newLocation = uri.toString();
|
||||
if (widget.routeUri.toString() == newLocation) {
|
||||
_controller.fetch(page: page);
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).go(newLocation);
|
||||
}
|
||||
|
||||
WarehouseStatusFilter _statusFromParam(String? value) {
|
||||
switch (value) {
|
||||
case 'active':
|
||||
return WarehouseStatusFilter.activeOnly;
|
||||
case 'inactive':
|
||||
return WarehouseStatusFilter.inactiveOnly;
|
||||
default:
|
||||
return WarehouseStatusFilter.all;
|
||||
}
|
||||
}
|
||||
|
||||
String? _encodeStatus(WarehouseStatusFilter filter) {
|
||||
switch (filter) {
|
||||
case WarehouseStatusFilter.all:
|
||||
return null;
|
||||
case WarehouseStatusFilter.activeOnly:
|
||||
return 'active';
|
||||
case WarehouseStatusFilter.inactiveOnly:
|
||||
return 'inactive';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openWarehouseForm(
|
||||
BuildContext context, {
|
||||
Warehouse? warehouse,
|
||||
@@ -337,13 +443,115 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
final saving = ValueNotifier<bool>(false);
|
||||
final codeError = ValueNotifier<String?>(null);
|
||||
final nameError = ValueNotifier<String?>(null);
|
||||
final zipcodeError = ValueNotifier<String?>(null);
|
||||
|
||||
await showDialog<bool>(
|
||||
var isApplyingPostalSelection = false;
|
||||
|
||||
void handleZipcodeChange() {
|
||||
if (isApplyingPostalSelection) {
|
||||
return;
|
||||
}
|
||||
final text = zipcodeController.text.trim();
|
||||
final selection = selectedPostalNotifier.value;
|
||||
if (text.isEmpty) {
|
||||
if (selection != null) {
|
||||
selectedPostalNotifier.value = null;
|
||||
}
|
||||
zipcodeError.value = null;
|
||||
return;
|
||||
}
|
||||
if (selection != null && selection.zipcode != text) {
|
||||
selectedPostalNotifier.value = null;
|
||||
}
|
||||
if (zipcodeError.value != null) {
|
||||
zipcodeError.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
void handlePostalSelectionChange() {
|
||||
if (selectedPostalNotifier.value != null) {
|
||||
zipcodeError.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
zipcodeController.addListener(handleZipcodeChange);
|
||||
selectedPostalNotifier.addListener(handlePostalSelectionChange);
|
||||
|
||||
await SuperportDialog.show<bool>(
|
||||
context: context,
|
||||
dialog: SuperportDialog(
|
||||
title: isEdit ? '창고 수정' : '창고 등록',
|
||||
description: '창고 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.',
|
||||
constraints: const BoxConstraints(maxWidth: 540),
|
||||
primaryAction: ValueListenableBuilder<bool>(
|
||||
valueListenable: saving,
|
||||
builder: (context, isSaving, _) {
|
||||
return ShadButton(
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () async {
|
||||
final code = codeController.text.trim();
|
||||
final name = nameController.text.trim();
|
||||
final zipcode = zipcodeController.text.trim();
|
||||
final address = addressController.text.trim();
|
||||
final note = noteController.text.trim();
|
||||
final selectedPostal = selectedPostalNotifier.value;
|
||||
|
||||
codeError.value = code.isEmpty ? '창고코드를 입력하세요.' : null;
|
||||
nameError.value = name.isEmpty ? '창고명을 입력하세요.' : null;
|
||||
zipcodeError.value =
|
||||
zipcode.isNotEmpty && selectedPostal == null
|
||||
? '우편번호 검색으로 주소를 선택하세요.'
|
||||
: null;
|
||||
|
||||
if (codeError.value != null ||
|
||||
nameError.value != null ||
|
||||
zipcodeError.value != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
final input = WarehouseInput(
|
||||
warehouseCode: code,
|
||||
warehouseName: name,
|
||||
zipcode: zipcode.isEmpty ? null : zipcode,
|
||||
addressDetail: address.isEmpty ? null : address,
|
||||
isActive: isActiveNotifier.value,
|
||||
note: note.isEmpty ? null : note,
|
||||
);
|
||||
final navigator = Navigator.of(context);
|
||||
final response = isEdit
|
||||
? await _controller.update(warehouseId!, input)
|
||||
: await _controller.create(input);
|
||||
saving.value = false;
|
||||
if (response != null && mounted) {
|
||||
if (!navigator.mounted) {
|
||||
return;
|
||||
}
|
||||
_showSnack(isEdit ? '창고를 수정했습니다.' : '창고를 등록했습니다.');
|
||||
navigator.pop(true);
|
||||
}
|
||||
},
|
||||
child: Text(isEdit ? '저장' : '등록'),
|
||||
);
|
||||
},
|
||||
),
|
||||
secondaryAction: ValueListenableBuilder<bool>(
|
||||
valueListenable: saving,
|
||||
builder: (context, isSaving, _) {
|
||||
return ShadButton.ghost(
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () => Navigator.of(context).pop(false),
|
||||
child: const Text('취소'),
|
||||
);
|
||||
},
|
||||
),
|
||||
child: Builder(
|
||||
builder: (dialogContext) {
|
||||
final theme = ShadTheme.of(dialogContext);
|
||||
final materialTheme = Theme.of(dialogContext);
|
||||
final navigator = Navigator.of(dialogContext);
|
||||
|
||||
Future<void> openPostalSearch() async {
|
||||
final keyword = zipcodeController.text.trim();
|
||||
final result = await showPostalSearchDialog(
|
||||
@@ -353,11 +561,13 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
isApplyingPostalSelection = true;
|
||||
zipcodeController
|
||||
..text = result.zipcode
|
||||
..selection = TextSelection.collapsed(
|
||||
offset: result.zipcode.length,
|
||||
);
|
||||
isApplyingPostalSelection = false;
|
||||
selectedPostalNotifier.value = result;
|
||||
if (result.fullAddress.isNotEmpty) {
|
||||
addressController
|
||||
@@ -368,90 +578,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
}
|
||||
}
|
||||
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.all(24),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 540),
|
||||
child: ShadCard(
|
||||
title: Text(
|
||||
isEdit ? '창고 수정' : '창고 등록',
|
||||
style: theme.textTheme.h3,
|
||||
),
|
||||
description: Text(
|
||||
'창고 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
footer: ValueListenableBuilder<bool>(
|
||||
valueListenable: saving,
|
||||
builder: (_, isSaving, __) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ShadButton.ghost(
|
||||
onPressed: isSaving ? null : () => navigator.pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ShadButton(
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () async {
|
||||
final code = codeController.text.trim();
|
||||
final name = nameController.text.trim();
|
||||
final zipcode = zipcodeController.text.trim();
|
||||
final address = addressController.text.trim();
|
||||
final note = noteController.text.trim();
|
||||
|
||||
codeError.value = code.isEmpty
|
||||
? '창고코드를 입력하세요.'
|
||||
: null;
|
||||
nameError.value = name.isEmpty
|
||||
? '창고명을 입력하세요.'
|
||||
: null;
|
||||
|
||||
if (codeError.value != null ||
|
||||
nameError.value != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
final input = WarehouseInput(
|
||||
warehouseCode: code,
|
||||
warehouseName: name,
|
||||
zipcode: zipcode.isEmpty ? null : zipcode,
|
||||
addressDetail: address.isEmpty
|
||||
? null
|
||||
: address,
|
||||
isActive: isActiveNotifier.value,
|
||||
note: note.isEmpty ? null : note,
|
||||
);
|
||||
final response = isEdit
|
||||
? await _controller.update(
|
||||
warehouseId!,
|
||||
input,
|
||||
)
|
||||
: await _controller.create(input);
|
||||
saving.value = false;
|
||||
if (response != null) {
|
||||
if (!navigator.mounted) {
|
||||
return;
|
||||
}
|
||||
if (mounted) {
|
||||
_showSnack(
|
||||
isEdit ? '창고를 수정했습니다.' : '창고를 등록했습니다.',
|
||||
);
|
||||
}
|
||||
navigator.pop(true);
|
||||
}
|
||||
},
|
||||
child: Text(isEdit ? '저장' : '등록'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -522,7 +649,10 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_FormField(
|
||||
ValueListenableBuilder<String?>(
|
||||
valueListenable: zipcodeError,
|
||||
builder: (_, zipcodeErrorText, __) {
|
||||
return _FormField(
|
||||
label: '우편번호',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -537,11 +667,16 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ShadButton.outline(
|
||||
onPressed: saving.value
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: saving,
|
||||
builder: (_, isSaving, __) {
|
||||
return ShadButton.outline(
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: openPostalSearch,
|
||||
child: const Text('검색'),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -566,8 +701,20 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
);
|
||||
},
|
||||
),
|
||||
if (zipcodeErrorText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
zipcodeErrorText,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: materialTheme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_FormField(
|
||||
@@ -617,13 +764,15 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
zipcodeController.removeListener(handleZipcodeChange);
|
||||
selectedPostalNotifier.removeListener(handlePostalSelectionChange);
|
||||
|
||||
if (!mounted) {
|
||||
codeController.dispose();
|
||||
nameController.dispose();
|
||||
@@ -635,6 +784,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
saving.dispose();
|
||||
codeError.dispose();
|
||||
nameError.dispose();
|
||||
zipcodeError.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -648,27 +798,26 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
|
||||
saving.dispose();
|
||||
codeError.dispose();
|
||||
nameError.dispose();
|
||||
zipcodeError.dispose();
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(Warehouse warehouse) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
final confirmed = await SuperportDialog.show<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('창고 삭제'),
|
||||
content: Text('"${warehouse.warehouseName}" 창고를 삭제하시겠습니까?'),
|
||||
dialog: SuperportDialog(
|
||||
title: '창고 삭제',
|
||||
description: '"${warehouse.warehouseName}" 창고를 삭제하시겠습니까?',
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
ShadButton.ghost(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
ShadButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('삭제'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && warehouse.id != null) {
|
||||
@@ -717,45 +866,51 @@ class _WarehouseTable extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final header = [
|
||||
'ID',
|
||||
'창고코드',
|
||||
'창고명',
|
||||
'우편번호',
|
||||
'상세주소',
|
||||
'사용',
|
||||
'삭제',
|
||||
'비고',
|
||||
'변경일시',
|
||||
'동작',
|
||||
].map((text) => ShadTableCell.header(child: Text(text))).toList();
|
||||
final columns = const [
|
||||
Text('ID'),
|
||||
Text('창고코드'),
|
||||
Text('창고명'),
|
||||
Text('우편번호'),
|
||||
Text('상세주소'),
|
||||
Text('사용'),
|
||||
Text('삭제'),
|
||||
Text('비고'),
|
||||
Text('변경일시'),
|
||||
Text('동작'),
|
||||
];
|
||||
|
||||
final rows = warehouses.map((warehouse) {
|
||||
return [
|
||||
warehouse.id?.toString() ?? '-',
|
||||
warehouse.warehouseCode,
|
||||
warehouse.warehouseName,
|
||||
warehouse.zipcode?.zipcode ?? '-',
|
||||
final cells = <Widget>[
|
||||
Text(warehouse.id?.toString() ?? '-'),
|
||||
Text(warehouse.warehouseCode),
|
||||
Text(warehouse.warehouseName),
|
||||
Text(warehouse.zipcode?.zipcode ?? '-'),
|
||||
Text(
|
||||
warehouse.addressDetail?.isEmpty ?? true
|
||||
? '-'
|
||||
: warehouse.addressDetail!,
|
||||
warehouse.isActive ? 'Y' : 'N',
|
||||
warehouse.isDeleted ? 'Y' : '-',
|
||||
warehouse.note?.isEmpty ?? true ? '-' : warehouse.note!,
|
||||
),
|
||||
Text(warehouse.isActive ? 'Y' : 'N'),
|
||||
Text(warehouse.isDeleted ? 'Y' : '-'),
|
||||
Text(warehouse.note?.isEmpty ?? true ? '-' : warehouse.note!),
|
||||
Text(
|
||||
warehouse.updatedAt == null
|
||||
? '-'
|
||||
: dateFormat.format(warehouse.updatedAt!.toLocal()),
|
||||
].map((text) => ShadTableCell(child: Text(text))).toList()..add(
|
||||
),
|
||||
];
|
||||
|
||||
cells.add(
|
||||
ShadTableCell(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
alignment: Alignment.centerRight,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
ShadButton.ghost(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: onEdit == null ? null : () => onEdit!(warehouse),
|
||||
child: const Icon(LucideIcons.pencil, size: 16),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
warehouse.isDeleted
|
||||
? ShadButton.ghost(
|
||||
size: ShadButtonSize.sm,
|
||||
@@ -775,17 +930,18 @@ class _WarehouseTable extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return cells;
|
||||
}).toList();
|
||||
|
||||
return SizedBox(
|
||||
height: 56.0 * (warehouses.length + 1),
|
||||
child: ShadTable.list(
|
||||
header: header,
|
||||
children: rows,
|
||||
return SuperportTable(
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
rowHeight: 56,
|
||||
maxHeight: 520,
|
||||
columnSpanExtent: (index) => index == 9
|
||||
? const FixedTableSpanExtent(160)
|
||||
: const FixedTableSpanExtent(140),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import 'package:superport_v2/core/constants/app_sections.dart';
|
||||
@@ -7,7 +9,9 @@ import 'package:superport_v2/features/masters/warehouse/domain/entities/warehous
|
||||
import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart';
|
||||
import 'package:superport_v2/widgets/app_layout.dart';
|
||||
import 'package:superport_v2/widgets/components/empty_state.dart';
|
||||
import 'package:superport_v2/widgets/components/feedback.dart';
|
||||
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
||||
import 'package:superport_v2/widgets/components/superport_date_picker.dart';
|
||||
|
||||
class ReportingPage extends StatefulWidget {
|
||||
const ReportingPage({super.key});
|
||||
@@ -18,12 +22,16 @@ class ReportingPage extends StatefulWidget {
|
||||
|
||||
class _ReportingPageState extends State<ReportingPage> {
|
||||
late final WarehouseRepository _warehouseRepository;
|
||||
final DateFormat _dateFormat = DateFormat('yyyy.MM.dd');
|
||||
final intl.DateFormat _dateFormat = intl.DateFormat('yyyy.MM.dd');
|
||||
|
||||
DateTimeRange? _dateRange;
|
||||
ReportTypeFilter _selectedType = ReportTypeFilter.all;
|
||||
ReportStatusFilter _selectedStatus = ReportStatusFilter.all;
|
||||
WarehouseFilterOption _selectedWarehouse = WarehouseFilterOption.all;
|
||||
DateTimeRange? _appliedDateRange;
|
||||
DateTimeRange? _pendingDateRange;
|
||||
ReportTypeFilter _appliedType = ReportTypeFilter.all;
|
||||
ReportTypeFilter _pendingType = ReportTypeFilter.all;
|
||||
ReportStatusFilter _appliedStatus = ReportStatusFilter.all;
|
||||
ReportStatusFilter _pendingStatus = ReportStatusFilter.all;
|
||||
WarehouseFilterOption _appliedWarehouse = WarehouseFilterOption.all;
|
||||
WarehouseFilterOption _pendingWarehouse = WarehouseFilterOption.all;
|
||||
|
||||
List<WarehouseFilterOption> _warehouseOptions = const [
|
||||
WarehouseFilterOption.all,
|
||||
@@ -63,14 +71,14 @@ class _ReportingPageState extends State<ReportingPage> {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_warehouseOptions = options;
|
||||
WarehouseFilterOption nextSelected = WarehouseFilterOption.all;
|
||||
for (final option in options) {
|
||||
if (option == _selectedWarehouse) {
|
||||
nextSelected = option;
|
||||
break;
|
||||
}
|
||||
}
|
||||
_selectedWarehouse = nextSelected;
|
||||
_appliedWarehouse = _resolveWarehouseOption(
|
||||
_appliedWarehouse,
|
||||
options,
|
||||
);
|
||||
_pendingWarehouse = _resolveWarehouseOption(
|
||||
_pendingWarehouse,
|
||||
options,
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -78,7 +86,8 @@ class _ReportingPageState extends State<ReportingPage> {
|
||||
setState(() {
|
||||
_warehouseError = '창고 목록을 불러오지 못했습니다. 잠시 후 다시 시도하세요.';
|
||||
_warehouseOptions = const [WarehouseFilterOption.all];
|
||||
_selectedWarehouse = WarehouseFilterOption.all;
|
||||
_appliedWarehouse = WarehouseFilterOption.all;
|
||||
_pendingWarehouse = WarehouseFilterOption.all;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
@@ -90,57 +99,70 @@ class _ReportingPageState extends State<ReportingPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickDateRange() async {
|
||||
final now = DateTime.now();
|
||||
final initialRange =
|
||||
_dateRange ??
|
||||
DateTimeRange(
|
||||
start: DateTime(
|
||||
now.year,
|
||||
now.month,
|
||||
now.day,
|
||||
).subtract(const Duration(days: 6)),
|
||||
end: DateTime(now.year, now.month, now.day),
|
||||
);
|
||||
final picked = await showDateRangePicker(
|
||||
context: context,
|
||||
initialDateRange: initialRange,
|
||||
firstDate: DateTime(now.year - 5),
|
||||
lastDate: DateTime(now.year + 2),
|
||||
helpText: '기간 선택',
|
||||
saveText: '적용',
|
||||
currentDate: now,
|
||||
locale: Localizations.localeOf(context),
|
||||
);
|
||||
if (picked != null && mounted) {
|
||||
setState(() {
|
||||
_dateRange = picked;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _resetFilters() {
|
||||
setState(() {
|
||||
_dateRange = null;
|
||||
_selectedType = ReportTypeFilter.all;
|
||||
_selectedStatus = ReportStatusFilter.all;
|
||||
_selectedWarehouse = WarehouseFilterOption.all;
|
||||
_appliedDateRange = null;
|
||||
_pendingDateRange = null;
|
||||
_appliedType = ReportTypeFilter.all;
|
||||
_pendingType = ReportTypeFilter.all;
|
||||
_appliedStatus = ReportStatusFilter.all;
|
||||
_pendingStatus = ReportStatusFilter.all;
|
||||
_appliedWarehouse = WarehouseFilterOption.all;
|
||||
_pendingWarehouse = WarehouseFilterOption.all;
|
||||
});
|
||||
}
|
||||
|
||||
void _applyFilters() {
|
||||
setState(() {
|
||||
_appliedDateRange = _pendingDateRange;
|
||||
_appliedType = _pendingType;
|
||||
_appliedStatus = _pendingStatus;
|
||||
_appliedWarehouse = _pendingWarehouse;
|
||||
});
|
||||
}
|
||||
|
||||
bool get _canExport {
|
||||
return _dateRange != null && _selectedType != ReportTypeFilter.all;
|
||||
return _appliedDateRange != null && _appliedType != ReportTypeFilter.all;
|
||||
}
|
||||
|
||||
bool get _hasCustomFilters {
|
||||
return _dateRange != null ||
|
||||
_selectedType != ReportTypeFilter.all ||
|
||||
_selectedStatus != ReportStatusFilter.all ||
|
||||
_selectedWarehouse != WarehouseFilterOption.all;
|
||||
return _appliedDateRange != null ||
|
||||
_appliedType != ReportTypeFilter.all ||
|
||||
_appliedStatus != ReportStatusFilter.all ||
|
||||
_appliedWarehouse != WarehouseFilterOption.all;
|
||||
}
|
||||
|
||||
String get _dateRangeLabel {
|
||||
final range = _dateRange;
|
||||
bool get _hasAppliedFilters => _hasCustomFilters;
|
||||
|
||||
bool get _hasDirtyFilters =>
|
||||
!_isSameRange(_pendingDateRange, _appliedDateRange) ||
|
||||
_pendingType != _appliedType ||
|
||||
_pendingStatus != _appliedStatus ||
|
||||
_pendingWarehouse != _appliedWarehouse;
|
||||
|
||||
bool _isSameRange(DateTimeRange? a, DateTimeRange? b) {
|
||||
if (identical(a, b)) {
|
||||
return true;
|
||||
}
|
||||
if (a == null || b == null) {
|
||||
return a == b;
|
||||
}
|
||||
return a.start == b.start && a.end == b.end;
|
||||
}
|
||||
|
||||
WarehouseFilterOption _resolveWarehouseOption(
|
||||
WarehouseFilterOption target,
|
||||
List<WarehouseFilterOption> options,
|
||||
) {
|
||||
for (final option in options) {
|
||||
if (option == target) {
|
||||
return option;
|
||||
}
|
||||
}
|
||||
return options.first;
|
||||
}
|
||||
|
||||
String _dateRangeLabel(DateTimeRange? range) {
|
||||
if (range == null) {
|
||||
return '기간 선택';
|
||||
}
|
||||
@@ -150,11 +172,7 @@ class _ReportingPageState extends State<ReportingPage> {
|
||||
String _formatDate(DateTime value) => _dateFormat.format(value);
|
||||
|
||||
void _handleExport(ReportExportFormat format) {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
messenger.clearSnackBars();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('${format.label} 다운로드 연동은 준비 중입니다.')),
|
||||
);
|
||||
SuperportToast.info(context, '${format.label} 다운로드 연동은 준비 중입니다.');
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -174,34 +192,56 @@ class _ReportingPageState extends State<ReportingPage> {
|
||||
onPressed: _canExport
|
||||
? () => _handleExport(ReportExportFormat.xlsx)
|
||||
: null,
|
||||
leading: const Icon(LucideIcons.fileDown, size: 16),
|
||||
leading: const Icon(lucide.LucideIcons.fileDown, size: 16),
|
||||
child: const Text('XLSX 다운로드'),
|
||||
),
|
||||
ShadButton.outline(
|
||||
onPressed: _canExport
|
||||
? () => _handleExport(ReportExportFormat.pdf)
|
||||
: null,
|
||||
leading: const Icon(LucideIcons.fileText, size: 16),
|
||||
leading: const Icon(lucide.LucideIcons.fileText, size: 16),
|
||||
child: const Text('PDF 다운로드'),
|
||||
),
|
||||
],
|
||||
toolbar: FilterBar(
|
||||
actionConfig: FilterBarActionConfig(
|
||||
onApply: _applyFilters,
|
||||
onReset: _resetFilters,
|
||||
hasPendingChanges: _hasDirtyFilters,
|
||||
hasActiveFilters: _hasAppliedFilters,
|
||||
),
|
||||
children: [
|
||||
ShadButton.outline(
|
||||
onPressed: _pickDateRange,
|
||||
leading: const Icon(LucideIcons.calendar, size: 16),
|
||||
child: Text(_dateRangeLabel),
|
||||
SizedBox(
|
||||
width: 220,
|
||||
child: SuperportDateRangePickerButton(
|
||||
value: _pendingDateRange ?? _appliedDateRange,
|
||||
dateFormat: _dateFormat,
|
||||
firstDate: DateTime(DateTime.now().year - 5),
|
||||
lastDate: DateTime(DateTime.now().year + 2),
|
||||
initialDateRange:
|
||||
_pendingDateRange ??
|
||||
_appliedDateRange ??
|
||||
DateTimeRange(
|
||||
start: DateTime.now().subtract(const Duration(days: 6)),
|
||||
end: DateTime.now(),
|
||||
),
|
||||
onChanged: (range) {
|
||||
setState(() {
|
||||
_pendingDateRange = range;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 200,
|
||||
child: ShadSelect<ReportTypeFilter>(
|
||||
key: ValueKey(_selectedType),
|
||||
initialValue: _selectedType,
|
||||
key: ValueKey(_pendingType),
|
||||
initialValue: _pendingType,
|
||||
selectedOptionBuilder: (_, value) => Text(value.label),
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
setState(() {
|
||||
_selectedType = value;
|
||||
_pendingType = value;
|
||||
});
|
||||
},
|
||||
options: [
|
||||
@@ -214,14 +254,14 @@ class _ReportingPageState extends State<ReportingPage> {
|
||||
width: 220,
|
||||
child: ShadSelect<WarehouseFilterOption>(
|
||||
key: ValueKey(
|
||||
'${_selectedWarehouse.cacheKey}-${_warehouseOptions.length}',
|
||||
'${_pendingWarehouse.cacheKey}-${_warehouseOptions.length}',
|
||||
),
|
||||
initialValue: _selectedWarehouse,
|
||||
initialValue: _pendingWarehouse,
|
||||
selectedOptionBuilder: (_, value) => Text(value.label),
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
setState(() {
|
||||
_selectedWarehouse = value;
|
||||
_pendingWarehouse = value;
|
||||
});
|
||||
},
|
||||
options: [
|
||||
@@ -233,13 +273,13 @@ class _ReportingPageState extends State<ReportingPage> {
|
||||
SizedBox(
|
||||
width: 200,
|
||||
child: ShadSelect<ReportStatusFilter>(
|
||||
key: ValueKey(_selectedStatus),
|
||||
initialValue: _selectedStatus,
|
||||
key: ValueKey(_pendingStatus),
|
||||
initialValue: _pendingStatus,
|
||||
selectedOptionBuilder: (_, value) => Text(value.label),
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
setState(() {
|
||||
_selectedStatus = value;
|
||||
_pendingStatus = value;
|
||||
});
|
||||
},
|
||||
options: [
|
||||
@@ -248,11 +288,6 @@ class _ReportingPageState extends State<ReportingPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
ShadButton.ghost(
|
||||
onPressed: _hasCustomFilters ? _resetFilters : null,
|
||||
leading: const Icon(LucideIcons.rotateCcw, size: 16),
|
||||
child: const Text('초기화'),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
@@ -264,7 +299,7 @@ class _ReportingPageState extends State<ReportingPage> {
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.circleAlert,
|
||||
lucide.LucideIcons.circleAlert,
|
||||
size: 16,
|
||||
color: theme.colorScheme.destructive,
|
||||
),
|
||||
@@ -280,7 +315,7 @@ class _ReportingPageState extends State<ReportingPage> {
|
||||
const SizedBox(width: 8),
|
||||
ShadButton.ghost(
|
||||
onPressed: _isLoadingWarehouses ? null : _loadWarehouses,
|
||||
leading: const Icon(LucideIcons.refreshCw, size: 16),
|
||||
leading: const Icon(lucide.LucideIcons.refreshCw, size: 16),
|
||||
child: const Text('재시도'),
|
||||
),
|
||||
],
|
||||
@@ -290,14 +325,12 @@ class _ReportingPageState extends State<ReportingPage> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('창고 목록을 불러오는 중입니다...', style: theme.textTheme.small),
|
||||
children: const [
|
||||
SuperportSkeleton(width: 180, height: 20),
|
||||
SizedBox(width: 12),
|
||||
SuperportSkeleton(width: 140, height: 20),
|
||||
SizedBox(width: 12),
|
||||
SuperportSkeleton(width: 120, height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -312,11 +345,13 @@ class _ReportingPageState extends State<ReportingPage> {
|
||||
children: [
|
||||
_SummaryRow(
|
||||
label: '기간',
|
||||
value: _dateRange == null ? '기간을 선택하세요.' : _dateRangeLabel,
|
||||
value: _appliedDateRange == null
|
||||
? '기간을 선택하세요.'
|
||||
: _dateRangeLabel(_appliedDateRange),
|
||||
),
|
||||
_SummaryRow(label: '유형', value: _selectedType.label),
|
||||
_SummaryRow(label: '창고', value: _selectedWarehouse.label),
|
||||
_SummaryRow(label: '상태', value: _selectedStatus.label),
|
||||
_SummaryRow(label: '유형', value: _appliedType.label),
|
||||
_SummaryRow(label: '창고', value: _appliedWarehouse.label),
|
||||
_SummaryRow(label: '상태', value: _appliedStatus.label),
|
||||
if (!_canExport)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
@@ -339,9 +374,10 @@ class _ReportingPageState extends State<ReportingPage> {
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 240,
|
||||
child: EmptyState(
|
||||
icon: LucideIcons.chartBar,
|
||||
message: '필터를 선택하고 다운로드하면 결과 미리보기가 제공됩니다.',
|
||||
child: SuperportEmptyState(
|
||||
icon: lucide.LucideIcons.chartBar,
|
||||
title: '미리보기 데이터가 없습니다.',
|
||||
description: '필터를 적용하거나 보고서를 다운로드하면 이 영역에 요약이 표시됩니다.',
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,25 +1,113 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../../../../widgets/spec_page.dart';
|
||||
import '../../../../../core/constants/app_sections.dart';
|
||||
import '../../../../../widgets/app_layout.dart';
|
||||
import '../models/postal_search_result.dart';
|
||||
import '../widgets/postal_search_dialog.dart';
|
||||
|
||||
class PostalSearchPage extends StatelessWidget {
|
||||
class PostalSearchPage extends StatefulWidget {
|
||||
const PostalSearchPage({super.key});
|
||||
|
||||
@override
|
||||
State<PostalSearchPage> createState() => _PostalSearchPageState();
|
||||
}
|
||||
|
||||
class _PostalSearchPageState extends State<PostalSearchPage> {
|
||||
PostalSearchResult? _lastSelection;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SpecPage(
|
||||
final theme = ShadTheme.of(context);
|
||||
|
||||
return AppLayout(
|
||||
title: '우편번호 검색',
|
||||
summary: '모달 기반 우편번호 검색 UI 구성을 정의합니다.',
|
||||
sections: [
|
||||
SpecSection(
|
||||
title: '모달 구성',
|
||||
items: [
|
||||
'검색어 [Text] 입력 필드',
|
||||
'결과 리스트: 우편번호 | 시도 | 시군구 | 도로명 | 건물번호',
|
||||
'선택 시 호출 화면에 우편번호/주소 전달',
|
||||
subtitle: '창고/고객사 등 주소 입력 폼에서 재사용되는 검색 모달입니다.',
|
||||
breadcrumbs: const [
|
||||
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
|
||||
AppBreadcrumbItem(label: '유틸리티', path: '/utilities/postal-search'),
|
||||
AppBreadcrumbItem(label: '우편번호 검색'),
|
||||
],
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: ShadCard(
|
||||
title: Text('우편번호 검색 모달 미리보기', style: theme.textTheme.h3),
|
||||
description: Text(
|
||||
'검색 버튼을 눌러 모달 UI를 확인하세요. 검색 API 연동은 이후 단계에서 진행됩니다.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
footer: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ShadButton(
|
||||
leading: const Icon(LucideIcons.search, size: 16),
|
||||
onPressed: () async {
|
||||
final result = await showPostalSearchDialog(context);
|
||||
if (result != null && mounted) {
|
||||
setState(() {
|
||||
_lastSelection = result;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: const Text('모달 열기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'우편번호를 검색한 뒤 결과 행을 클릭하면 선택한 주소가 폼에 채워집니다.',
|
||||
style: theme.textTheme.p,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_lastSelection == null)
|
||||
Text('선택한 주소가 없습니다.', style: theme.textTheme.muted)
|
||||
else
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: theme.colorScheme.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'선택된 우편번호',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_lastSelection!.zipcode,
|
||||
style: theme.textTheme.h4,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'주소 구성요소',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_lastSelection!.fullAddress.isEmpty
|
||||
? '주소 정보가 제공되지 않았습니다.'
|
||||
: _lastSelection!.fullAddress,
|
||||
style: theme.textTheme.p,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +255,14 @@ class _PostalSearchDialogState extends State<_PostalSearchDialog> {
|
||||
],
|
||||
],
|
||||
onRowTap: (index) {
|
||||
navigator.pop(_results[index]);
|
||||
if (_results.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final adjustedIndex = (index - 1).clamp(
|
||||
0,
|
||||
_results.length - 1,
|
||||
);
|
||||
navigator.pop(_results[adjustedIndex]);
|
||||
},
|
||||
emptyLabel: '검색 결과가 없습니다.',
|
||||
),
|
||||
|
||||
@@ -4,7 +4,9 @@ import 'package:dio/dio.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import 'core/network/api_client.dart';
|
||||
import 'core/network/api_error.dart';
|
||||
import 'core/network/interceptors/auth_interceptor.dart';
|
||||
import 'core/services/token_storage.dart';
|
||||
import 'features/masters/customer/data/repositories/customer_repository_remote.dart';
|
||||
import 'features/masters/customer/domain/repositories/customer_repository.dart';
|
||||
import 'features/masters/group/data/repositories/group_repository_remote.dart';
|
||||
@@ -55,13 +57,20 @@ Future<void> initInjection({
|
||||
final dio = Dio(options);
|
||||
|
||||
// 인터셉터 등록 (Auth 등)
|
||||
dio.interceptors.add(AuthInterceptor());
|
||||
final tokenStorage = createTokenStorage();
|
||||
sl.registerLazySingleton<TokenStorage>(() => tokenStorage);
|
||||
sl.registerLazySingleton<ApiErrorMapper>(ApiErrorMapper.new);
|
||||
|
||||
final authInterceptor = AuthInterceptor(tokenStorage: tokenStorage, dio: dio);
|
||||
dio.interceptors.add(authInterceptor);
|
||||
|
||||
// 개발용 로거는 필요 시 추가 (pretty_dio_logger 등)
|
||||
// if (!kReleaseMode) { dio.interceptors.add(PrettyDioLogger(...)); }
|
||||
|
||||
// ApiClient 등록
|
||||
sl.registerLazySingleton<ApiClient>(() => ApiClient(dio: dio));
|
||||
sl.registerLazySingleton<ApiClient>(
|
||||
() => ApiClient(dio: dio, errorMapper: sl<ApiErrorMapper>()),
|
||||
);
|
||||
|
||||
// 리포지토리 등록 (예: 벤더)
|
||||
sl.registerLazySingleton<VendorRepository>(
|
||||
|
||||
@@ -4,7 +4,10 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import 'core/config/environment.dart';
|
||||
import 'core/routing/app_router.dart';
|
||||
import 'core/theme/superport_shad_theme.dart';
|
||||
import 'core/theme/theme_controller.dart';
|
||||
import 'injection_container.dart';
|
||||
import 'core/permissions/permission_manager.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -13,11 +16,40 @@ Future<void> main() async {
|
||||
runApp(const SuperportApp());
|
||||
}
|
||||
|
||||
class SuperportApp extends StatelessWidget {
|
||||
class SuperportApp extends StatefulWidget {
|
||||
const SuperportApp({super.key});
|
||||
|
||||
@override
|
||||
State<SuperportApp> createState() => _SuperportAppState();
|
||||
}
|
||||
|
||||
class _SuperportAppState extends State<SuperportApp> {
|
||||
late final ThemeController _themeController;
|
||||
late final PermissionManager _permissionManager;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_themeController = ThemeController();
|
||||
_permissionManager = PermissionManager();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_themeController.dispose();
|
||||
_permissionManager.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PermissionScope(
|
||||
manager: _permissionManager,
|
||||
child: ThemeControllerScope(
|
||||
controller: _themeController,
|
||||
child: AnimatedBuilder(
|
||||
animation: _themeController,
|
||||
builder: (context, _) {
|
||||
return ShadApp.router(
|
||||
title: 'Superport v2',
|
||||
routerConfig: appRouter,
|
||||
@@ -28,13 +60,12 @@ class SuperportApp extends StatelessWidget {
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
theme: ShadThemeData(
|
||||
colorScheme: const ShadSlateColorScheme.light(),
|
||||
brightness: Brightness.light,
|
||||
theme: SuperportShadTheme.light(),
|
||||
darkTheme: SuperportShadTheme.dark(),
|
||||
themeMode: _themeController.mode,
|
||||
);
|
||||
},
|
||||
),
|
||||
darkTheme: ShadThemeData(
|
||||
colorScheme: const ShadSlateColorScheme.dark(),
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,15 +35,8 @@ class AppLayout extends StatelessWidget {
|
||||
_BreadcrumbBar(items: breadcrumbs),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
PageHeader(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
actions: actions,
|
||||
),
|
||||
if (toolbar != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
toolbar!,
|
||||
],
|
||||
PageHeader(title: title, subtitle: subtitle, actions: actions),
|
||||
if (toolbar != null) ...[const SizedBox(height: 16), toolbar!],
|
||||
const SizedBox(height: 24),
|
||||
child,
|
||||
],
|
||||
@@ -54,11 +47,7 @@ class AppLayout extends StatelessWidget {
|
||||
}
|
||||
|
||||
class AppBreadcrumbItem {
|
||||
const AppBreadcrumbItem({
|
||||
required this.label,
|
||||
this.path,
|
||||
this.onTap,
|
||||
});
|
||||
const AppBreadcrumbItem({required this.label, this.path, this.onTap});
|
||||
|
||||
final String label;
|
||||
final String? path;
|
||||
@@ -94,7 +83,10 @@ class _BreadcrumbBar extends StatelessWidget {
|
||||
size: 14,
|
||||
color: colorScheme.mutedForeground,
|
||||
),
|
||||
_BreadcrumbChip(item: items[index], isLast: index == items.length - 1),
|
||||
_BreadcrumbChip(
|
||||
item: items[index],
|
||||
isLast: index == items.length - 1,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
@@ -113,7 +105,9 @@ class _BreadcrumbChip extends StatelessWidget {
|
||||
final label = Text(
|
||||
item.label,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: isLast ? theme.colorScheme.foreground : theme.colorScheme.mutedForeground,
|
||||
color: isLast
|
||||
? theme.colorScheme.foreground
|
||||
: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
|
||||
import '../core/constants/app_sections.dart';
|
||||
import '../core/theme/theme_controller.dart';
|
||||
import '../core/permissions/permission_manager.dart';
|
||||
|
||||
class AppShell extends StatelessWidget {
|
||||
const AppShell({
|
||||
@@ -19,6 +21,13 @@ class AppShell extends StatelessWidget {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth >= 960;
|
||||
final manager = PermissionScope.of(context);
|
||||
final filteredPages = <AppPageDescriptor>[
|
||||
for (final section in appSections)
|
||||
for (final page in section.pages)
|
||||
if (manager.can(page.path, PermissionAction.view)) page,
|
||||
];
|
||||
final pages = filteredPages.isEmpty ? allAppPages : filteredPages;
|
||||
if (isWide) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -26,14 +35,14 @@ class AppShell extends StatelessWidget {
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: '로그아웃',
|
||||
icon: const Icon(LucideIcons.logOut),
|
||||
icon: const Icon(lucide.LucideIcons.logOut),
|
||||
onPressed: () => context.go(loginRoutePath),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Row(
|
||||
children: [
|
||||
_NavigationRail(currentLocation: currentLocation),
|
||||
_NavigationRail(currentLocation: currentLocation, pages: pages),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(child: child),
|
||||
],
|
||||
@@ -47,7 +56,7 @@ class AppShell extends StatelessWidget {
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: '로그아웃',
|
||||
icon: const Icon(LucideIcons.logOut),
|
||||
icon: const Icon(lucide.LucideIcons.logOut),
|
||||
onPressed: () => context.go(loginRoutePath),
|
||||
),
|
||||
],
|
||||
@@ -60,6 +69,7 @@ class AppShell extends StatelessWidget {
|
||||
Navigator.of(context).pop();
|
||||
context.go(path);
|
||||
},
|
||||
pages: pages,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -71,16 +81,18 @@ class AppShell extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _NavigationRail extends StatelessWidget {
|
||||
const _NavigationRail({required this.currentLocation});
|
||||
const _NavigationRail({required this.currentLocation, required this.pages});
|
||||
|
||||
final String currentLocation;
|
||||
final List<AppPageDescriptor> pages;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pages = allAppPages;
|
||||
final selectedIndex = _selectedIndex(currentLocation, pages);
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final themeController = ThemeControllerScope.of(context);
|
||||
final currentThemeMode = themeController.mode;
|
||||
|
||||
return Container(
|
||||
width: 104,
|
||||
@@ -151,6 +163,13 @@ class _NavigationRail extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 16),
|
||||
child: _ThemeMenuButton(
|
||||
mode: currentThemeMode,
|
||||
onChanged: themeController.update,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -158,19 +177,37 @@ class _NavigationRail extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _NavigationList extends StatelessWidget {
|
||||
const _NavigationList({required this.currentLocation, required this.onTap});
|
||||
const _NavigationList({
|
||||
required this.currentLocation,
|
||||
required this.onTap,
|
||||
required this.pages,
|
||||
});
|
||||
|
||||
final String currentLocation;
|
||||
final ValueChanged<String> onTap;
|
||||
final List<AppPageDescriptor> pages;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pages = allAppPages;
|
||||
final selectedIndex = _selectedIndex(currentLocation, pages);
|
||||
final themeController = ThemeControllerScope.of(context);
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: pages.length,
|
||||
itemCount: pages.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == pages.length) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: _ThemeMenuButton(
|
||||
mode: themeController.mode,
|
||||
onChanged: (mode) {
|
||||
themeController.update(mode);
|
||||
Navigator.of(context).maybePop();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final page = pages[index];
|
||||
final selected = index == selectedIndex;
|
||||
return ListTile(
|
||||
@@ -190,6 +227,77 @@ class _NavigationList extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ThemeMenuButton extends StatelessWidget {
|
||||
const _ThemeMenuButton({required this.mode, required this.onChanged});
|
||||
|
||||
final ThemeMode mode;
|
||||
final ValueChanged<ThemeMode> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final label = _label(mode);
|
||||
final icon = _icon(mode);
|
||||
|
||||
return PopupMenuButton<ThemeMode>(
|
||||
tooltip: '테마 변경',
|
||||
onSelected: onChanged,
|
||||
itemBuilder: (context) => ThemeMode.values
|
||||
.map(
|
||||
(value) => PopupMenuItem<ThemeMode>(
|
||||
value: value,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(_icon(value), size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(_label(value)),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text('테마 · $label', style: theme.textTheme.labelSmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _label(ThemeMode mode) {
|
||||
switch (mode) {
|
||||
case ThemeMode.system:
|
||||
return '시스템';
|
||||
case ThemeMode.light:
|
||||
return '라이트';
|
||||
case ThemeMode.dark:
|
||||
return '다크';
|
||||
}
|
||||
}
|
||||
|
||||
static IconData _icon(ThemeMode mode) {
|
||||
switch (mode) {
|
||||
case ThemeMode.system:
|
||||
return lucide.LucideIcons.monitorCog;
|
||||
case ThemeMode.light:
|
||||
return lucide.LucideIcons.sun;
|
||||
case ThemeMode.dark:
|
||||
return lucide.LucideIcons.moon;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int _selectedIndex(String location, List<AppPageDescriptor> pages) {
|
||||
final normalized = location.toLowerCase();
|
||||
final exact = pages.indexWhere(
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
class EmptyState extends StatelessWidget {
|
||||
const EmptyState({super.key, required this.message, this.icon});
|
||||
/// 데이터가 없을 때 사용자에게 명확한 안내를 제공하는 공통 위젯.
|
||||
class SuperportEmptyState extends StatelessWidget {
|
||||
const SuperportEmptyState({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.description,
|
||||
this.icon = lucide.LucideIcons.inbox,
|
||||
this.action,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final String title;
|
||||
final String? description;
|
||||
final IconData? icon;
|
||||
final Widget? action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -16,8 +26,17 @@ class EmptyState extends StatelessWidget {
|
||||
children: [
|
||||
if (icon != null)
|
||||
Icon(icon, size: 48, color: theme.colorScheme.mutedForeground),
|
||||
if (icon != null) const SizedBox(height: 16),
|
||||
Text(message, style: theme.textTheme.muted),
|
||||
const SizedBox(height: 16),
|
||||
Text(title, style: theme.textTheme.h4),
|
||||
if (description != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
description!,
|
||||
style: theme.textTheme.muted,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
if (action != null) ...[const SizedBox(height: 20), action!],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
131
lib/widgets/components/feedback.dart
Normal file
131
lib/widgets/components/feedback.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
/// Superport 전역에서 사용하는 토스트/스낵바 헬퍼.
|
||||
class SuperportToast {
|
||||
SuperportToast._();
|
||||
|
||||
static void success(BuildContext context, String message) {
|
||||
_show(context, message, _ToastVariant.success);
|
||||
}
|
||||
|
||||
static void info(BuildContext context, String message) {
|
||||
_show(context, message, _ToastVariant.info);
|
||||
}
|
||||
|
||||
static void warning(BuildContext context, String message) {
|
||||
_show(context, message, _ToastVariant.warning);
|
||||
}
|
||||
|
||||
static void error(BuildContext context, String message) {
|
||||
_show(context, message, _ToastVariant.error);
|
||||
}
|
||||
|
||||
static void _show(
|
||||
BuildContext context,
|
||||
String message,
|
||||
_ToastVariant variant,
|
||||
) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final (Color background, Color foreground) = switch (variant) {
|
||||
_ToastVariant.success => (
|
||||
theme.colorScheme.primary,
|
||||
theme.colorScheme.primaryForeground,
|
||||
),
|
||||
_ToastVariant.info => (
|
||||
theme.colorScheme.accent,
|
||||
theme.colorScheme.accentForeground,
|
||||
),
|
||||
_ToastVariant.warning => (
|
||||
theme.colorScheme.secondary,
|
||||
theme.colorScheme.secondaryForeground,
|
||||
),
|
||||
_ToastVariant.error => (
|
||||
theme.colorScheme.destructive,
|
||||
theme.colorScheme.destructiveForeground,
|
||||
),
|
||||
};
|
||||
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
messenger
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
message,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: foreground,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
backgroundColor: background,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _ToastVariant { success, info, warning, error }
|
||||
|
||||
/// 기본 골격을 표현하는 스켈레톤 블록.
|
||||
class SuperportSkeleton extends StatelessWidget {
|
||||
const SuperportSkeleton({
|
||||
super.key,
|
||||
this.width,
|
||||
this.height = 16,
|
||||
this.borderRadius = const BorderRadius.all(Radius.circular(8)),
|
||||
});
|
||||
|
||||
final double? width;
|
||||
final double height;
|
||||
final BorderRadius borderRadius;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 600),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.muted,
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
width: width,
|
||||
height: height,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 리스트 데이터를 대체하는 반복 스켈레톤 레이아웃.
|
||||
class SuperportSkeletonList extends StatelessWidget {
|
||||
const SuperportSkeletonList({
|
||||
super.key,
|
||||
this.itemCount = 6,
|
||||
this.height = 56,
|
||||
this.gap = 12,
|
||||
this.padding = const EdgeInsets.all(16),
|
||||
});
|
||||
|
||||
final int itemCount;
|
||||
final double height;
|
||||
final double gap;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: padding,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < itemCount; i++) ...[
|
||||
SuperportSkeleton(
|
||||
height: height,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
if (i != itemCount - 1) SizedBox(height: gap),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,23 +3,162 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
/// 검색/필터 영역을 위한 공통 래퍼.
|
||||
class FilterBar extends StatelessWidget {
|
||||
const FilterBar({super.key, required this.children});
|
||||
const FilterBar({
|
||||
super.key,
|
||||
required this.children,
|
||||
this.title = '검색 및 필터',
|
||||
this.actions,
|
||||
this.spacing = 16,
|
||||
this.runSpacing = 16,
|
||||
this.actionConfig,
|
||||
this.leading,
|
||||
});
|
||||
|
||||
final List<Widget> children;
|
||||
final String? title;
|
||||
final List<Widget>? actions;
|
||||
final double spacing;
|
||||
final double runSpacing;
|
||||
final FilterBarActionConfig? actionConfig;
|
||||
final Widget? leading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final computedActions = _resolveActions(context);
|
||||
final hasHeading =
|
||||
(title != null && title!.isNotEmpty) || computedActions.isNotEmpty;
|
||||
|
||||
return ShadCard(
|
||||
title: Text('검색 및 필터', style: theme.textTheme.h3),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
children: children,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (hasHeading)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (leading != null) ...[
|
||||
leading!,
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
if (title != null && title!.isNotEmpty)
|
||||
Text(title!, style: theme.textTheme.h3),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (computedActions.isNotEmpty)
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.end,
|
||||
children: computedActions,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Wrap(spacing: spacing, runSpacing: runSpacing, children: children),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _resolveActions(BuildContext context) {
|
||||
final items = <Widget>[];
|
||||
final config = actionConfig;
|
||||
if (config != null) {
|
||||
final badge = _buildStatusBadge(context, config);
|
||||
if (badge != null) {
|
||||
items.add(badge);
|
||||
}
|
||||
items.add(
|
||||
ShadButton(
|
||||
key: config.applyKey,
|
||||
onPressed: config.canApply ? config.onApply : null,
|
||||
child: Text(config.applyLabel),
|
||||
),
|
||||
);
|
||||
if (config.shouldShowReset) {
|
||||
items.add(
|
||||
ShadButton.ghost(
|
||||
key: config.resetKey,
|
||||
onPressed: config.canReset ? config.onReset : null,
|
||||
child: Text(config.resetLabel),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (actions != null && actions!.isNotEmpty) {
|
||||
items.addAll(actions!);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
Widget? _buildStatusBadge(
|
||||
BuildContext context,
|
||||
FilterBarActionConfig config,
|
||||
) {
|
||||
final theme = ShadTheme.of(context);
|
||||
if (config.hasPendingChanges) {
|
||||
return ShadBadge(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Text('미적용 변경', style: theme.textTheme.small),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (config.hasActiveFilters) {
|
||||
return ShadBadge.outline(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Text('필터 적용됨', style: theme.textTheme.small),
|
||||
),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 필터 적용/초기화 버튼 구성을 위한 상태 객체.
|
||||
class FilterBarActionConfig {
|
||||
const FilterBarActionConfig({
|
||||
required this.onApply,
|
||||
required this.onReset,
|
||||
this.hasPendingChanges = false,
|
||||
this.hasActiveFilters = false,
|
||||
this.applyLabel = '검색 적용',
|
||||
this.resetLabel = '초기화',
|
||||
this.applyEnabled,
|
||||
this.resetEnabled,
|
||||
this.showReset,
|
||||
this.applyKey,
|
||||
this.resetKey,
|
||||
});
|
||||
|
||||
final VoidCallback onApply;
|
||||
final VoidCallback onReset;
|
||||
final bool hasPendingChanges;
|
||||
final bool hasActiveFilters;
|
||||
final String applyLabel;
|
||||
final String resetLabel;
|
||||
final bool? applyEnabled;
|
||||
final bool? resetEnabled;
|
||||
final bool? showReset;
|
||||
final Key? applyKey;
|
||||
final Key? resetKey;
|
||||
|
||||
bool get canApply => applyEnabled ?? hasPendingChanges;
|
||||
bool get shouldShowReset =>
|
||||
showReset ?? (hasActiveFilters || hasPendingChanges);
|
||||
bool get canReset => resetEnabled ?? shouldShowReset;
|
||||
}
|
||||
|
||||
176
lib/widgets/components/form_field.dart
Normal file
176
lib/widgets/components/form_field.dart
Normal file
@@ -0,0 +1,176 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
const double _kFieldSpacing = 8;
|
||||
const double _kFieldCaptionSpacing = 6;
|
||||
|
||||
/// 폼 필드 라벨과 본문을 일관되게 배치하기 위한 위젯.
|
||||
class SuperportFormField extends StatelessWidget {
|
||||
const SuperportFormField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.child,
|
||||
this.required = false,
|
||||
this.caption,
|
||||
this.errorText,
|
||||
this.trailing,
|
||||
this.spacing = _kFieldSpacing,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Widget child;
|
||||
final bool required;
|
||||
final String? caption;
|
||||
final String? errorText;
|
||||
final Widget? trailing;
|
||||
final double spacing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final captionStyle = theme.textTheme.muted.copyWith(fontSize: 12);
|
||||
final errorStyle = theme.textTheme.small.copyWith(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.destructive,
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _FieldLabel(label: label, required: required),
|
||||
),
|
||||
if (trailing != null) trailing!,
|
||||
],
|
||||
),
|
||||
SizedBox(height: spacing),
|
||||
child,
|
||||
if (errorText != null && errorText!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: _kFieldCaptionSpacing),
|
||||
child: Text(errorText!, style: errorStyle),
|
||||
)
|
||||
else if (caption != null && caption!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: _kFieldCaptionSpacing),
|
||||
child: Text(caption!, style: captionStyle),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ShadInput`을 Superport 스타일에 맞게 설정한 텍스트 필드.
|
||||
class SuperportTextInput extends StatelessWidget {
|
||||
const SuperportTextInput({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.placeholder,
|
||||
this.onChanged,
|
||||
this.onSubmitted,
|
||||
this.keyboardType,
|
||||
this.enabled = true,
|
||||
this.readOnly = false,
|
||||
this.maxLines = 1,
|
||||
this.leading,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
final TextEditingController? controller;
|
||||
final Widget? placeholder;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
final TextInputType? keyboardType;
|
||||
final bool enabled;
|
||||
final bool readOnly;
|
||||
final int maxLines;
|
||||
final Widget? leading;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ShadInput(
|
||||
controller: controller,
|
||||
placeholder: placeholder,
|
||||
enabled: enabled,
|
||||
readOnly: readOnly,
|
||||
keyboardType: keyboardType,
|
||||
maxLines: maxLines,
|
||||
leading: leading,
|
||||
trailing: trailing,
|
||||
onChanged: onChanged,
|
||||
onSubmitted: onSubmitted,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ShadSwitch`를 라벨과 함께 사용하기 위한 헬퍼.
|
||||
class SuperportSwitchField extends StatelessWidget {
|
||||
const SuperportSwitchField({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.label,
|
||||
this.caption,
|
||||
});
|
||||
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
final String? label;
|
||||
final String? caption;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (label != null) Text(label!, style: theme.textTheme.small),
|
||||
const SizedBox(height: 8),
|
||||
ShadSwitch(value: value, onChanged: onChanged),
|
||||
if (caption != null && caption!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: _kFieldCaptionSpacing),
|
||||
child: Text(caption!, style: theme.textTheme.muted),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FieldLabel extends StatelessWidget {
|
||||
const _FieldLabel({required this.label, required this.required});
|
||||
|
||||
final String label;
|
||||
final bool required;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final textStyle = theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
);
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label, style: textStyle),
|
||||
if (required)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: Text(
|
||||
'*',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.destructive,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,7 @@ class PageHeader extends StatelessWidget {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (leading != null) ...[
|
||||
leading!,
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
if (leading != null) ...[leading!, const SizedBox(width: 16)],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -42,16 +39,9 @@ class PageHeader extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
if (actions != null && actions!.isNotEmpty) ...[
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: actions!,
|
||||
),
|
||||
],
|
||||
if (trailing != null) ...[
|
||||
const SizedBox(width: 16),
|
||||
trailing!,
|
||||
Wrap(spacing: 12, runSpacing: 12, children: actions!),
|
||||
],
|
||||
if (trailing != null) ...[const SizedBox(width: 16), trailing!],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,98 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
const double desktopBreakpoint = 1200;
|
||||
const double tabletBreakpoint = 960;
|
||||
|
||||
enum DeviceBreakpoint { mobile, tablet, desktop }
|
||||
|
||||
DeviceBreakpoint breakpointForWidth(double width) {
|
||||
if (width >= desktopBreakpoint) {
|
||||
return DeviceBreakpoint.desktop;
|
||||
}
|
||||
if (width >= tabletBreakpoint) {
|
||||
return DeviceBreakpoint.tablet;
|
||||
}
|
||||
return DeviceBreakpoint.mobile;
|
||||
}
|
||||
|
||||
bool isDesktop(double width) => width >= desktopBreakpoint;
|
||||
bool isTablet(double width) => width >= tabletBreakpoint && width < desktopBreakpoint;
|
||||
bool isTablet(double width) =>
|
||||
width >= tabletBreakpoint && width < desktopBreakpoint;
|
||||
bool isMobile(double width) => width < tabletBreakpoint;
|
||||
|
||||
bool isDesktopContext(BuildContext context) =>
|
||||
isDesktop(MediaQuery.of(context).size.width);
|
||||
bool isTabletContext(BuildContext context) =>
|
||||
isTablet(MediaQuery.of(context).size.width);
|
||||
bool isMobileContext(BuildContext context) =>
|
||||
isMobile(MediaQuery.of(context).size.width);
|
||||
|
||||
class ResponsiveBreakpoints {
|
||||
ResponsiveBreakpoints._(this.width) : breakpoint = breakpointForWidth(width);
|
||||
|
||||
final double width;
|
||||
final DeviceBreakpoint breakpoint;
|
||||
|
||||
bool get isMobile => breakpoint == DeviceBreakpoint.mobile;
|
||||
bool get isTablet => breakpoint == DeviceBreakpoint.tablet;
|
||||
bool get isDesktop => breakpoint == DeviceBreakpoint.desktop;
|
||||
|
||||
static ResponsiveBreakpoints of(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
return ResponsiveBreakpoints._(size.width);
|
||||
}
|
||||
}
|
||||
|
||||
class ResponsiveLayoutBuilder extends StatelessWidget {
|
||||
const ResponsiveLayoutBuilder({
|
||||
super.key,
|
||||
required this.mobile,
|
||||
this.tablet,
|
||||
required this.desktop,
|
||||
});
|
||||
|
||||
final WidgetBuilder mobile;
|
||||
final WidgetBuilder? tablet;
|
||||
final WidgetBuilder desktop;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final breakpoint = breakpointForWidth(constraints.maxWidth);
|
||||
switch (breakpoint) {
|
||||
case DeviceBreakpoint.mobile:
|
||||
return mobile(context);
|
||||
case DeviceBreakpoint.tablet:
|
||||
final tabletBuilder = tablet ?? desktop;
|
||||
return tabletBuilder(context);
|
||||
case DeviceBreakpoint.desktop:
|
||||
return desktop(context);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ResponsiveVisibility extends StatelessWidget {
|
||||
const ResponsiveVisibility({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.replacement = const SizedBox.shrink(),
|
||||
this.visibleOn = const {
|
||||
DeviceBreakpoint.mobile,
|
||||
DeviceBreakpoint.tablet,
|
||||
DeviceBreakpoint.desktop,
|
||||
},
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Widget replacement;
|
||||
final Set<DeviceBreakpoint> visibleOn;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final breakpoint = ResponsiveBreakpoints.of(context).breakpoint;
|
||||
return visibleOn.contains(breakpoint) ? child : replacement;
|
||||
}
|
||||
}
|
||||
|
||||
133
lib/widgets/components/superport_date_picker.dart
Normal file
133
lib/widgets/components/superport_date_picker.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
/// 단일 날짜 선택을 위한 공통 버튼 위젯.
|
||||
class SuperportDatePickerButton extends StatelessWidget {
|
||||
const SuperportDatePickerButton({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.firstDate,
|
||||
this.lastDate,
|
||||
this.dateFormat,
|
||||
this.placeholder = '날짜 선택',
|
||||
this.enabled = true,
|
||||
this.initialDate,
|
||||
});
|
||||
|
||||
final DateTime? value;
|
||||
final ValueChanged<DateTime> onChanged;
|
||||
final DateTime? firstDate;
|
||||
final DateTime? lastDate;
|
||||
final intl.DateFormat? dateFormat;
|
||||
final String placeholder;
|
||||
final bool enabled;
|
||||
final DateTime? initialDate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final format = dateFormat ?? intl.DateFormat('yyyy-MM-dd');
|
||||
final displayText = value != null ? format.format(value!) : placeholder;
|
||||
return ShadButton.outline(
|
||||
onPressed: !enabled
|
||||
? null
|
||||
: () async {
|
||||
final now = DateTime.now();
|
||||
final baseFirst = firstDate ?? DateTime(now.year - 10);
|
||||
final baseLast = lastDate ?? DateTime(now.year + 5);
|
||||
final seed = value ?? initialDate ?? now;
|
||||
final adjustedInitial = seed.clamp(baseFirst, baseLast);
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: adjustedInitial,
|
||||
firstDate: baseFirst,
|
||||
lastDate: baseLast,
|
||||
);
|
||||
if (picked != null) {
|
||||
onChanged(picked);
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(displayText),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(lucide.LucideIcons.calendar, size: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 날짜 범위 선택을 위한 공통 버튼 위젯.
|
||||
class SuperportDateRangePickerButton extends StatelessWidget {
|
||||
const SuperportDateRangePickerButton({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.firstDate,
|
||||
this.lastDate,
|
||||
this.dateFormat,
|
||||
this.placeholder = '기간 선택',
|
||||
this.enabled = true,
|
||||
this.initialDateRange,
|
||||
});
|
||||
|
||||
final DateTimeRange? value;
|
||||
final ValueChanged<DateTimeRange?> onChanged;
|
||||
final DateTime? firstDate;
|
||||
final DateTime? lastDate;
|
||||
final intl.DateFormat? dateFormat;
|
||||
final String placeholder;
|
||||
final bool enabled;
|
||||
final DateTimeRange? initialDateRange;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final format = dateFormat ?? intl.DateFormat('yyyy-MM-dd');
|
||||
final label = value == null
|
||||
? placeholder
|
||||
: '${format.format(value!.start)} ~ ${format.format(value!.end)}';
|
||||
return ShadButton.outline(
|
||||
onPressed: !enabled
|
||||
? null
|
||||
: () async {
|
||||
final now = DateTime.now();
|
||||
final baseFirst = firstDate ?? DateTime(now.year - 10);
|
||||
final baseLast = lastDate ?? DateTime(now.year + 5);
|
||||
final initialRange = value ?? initialDateRange;
|
||||
final currentDate = initialRange?.start ?? now;
|
||||
final picked = await showDateRangePicker(
|
||||
context: context,
|
||||
firstDate: baseFirst,
|
||||
lastDate: baseLast,
|
||||
initialDateRange: initialRange,
|
||||
currentDate: currentDate.clamp(baseFirst, baseLast),
|
||||
);
|
||||
if (picked != null) {
|
||||
onChanged(picked);
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(lucide.LucideIcons.calendar, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(child: Text(label, overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension _ClampDate on DateTime {
|
||||
DateTime clamp(DateTime min, DateTime max) {
|
||||
if (isBefore(min)) return min;
|
||||
if (isAfter(max)) return max;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,327 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
/// 공통 모달 다이얼로그.
|
||||
import 'keyboard_shortcuts.dart';
|
||||
|
||||
const double _kDialogMaxWidth = 640;
|
||||
const double _kDialogMobileBreakpoint = 640;
|
||||
const EdgeInsets _kDialogDesktopInset = EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 32,
|
||||
);
|
||||
const EdgeInsets _kDialogBodyPadding = EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 24,
|
||||
);
|
||||
|
||||
/// 공통 모달 다이얼로그 scaffold.
|
||||
///
|
||||
/// - ShadCard 기반으로 헤더/본문/푸터 영역을 분리한다.
|
||||
/// - 모바일에서는 전체 화면으로 확장되며 시스템 인셋을 자동 반영한다.
|
||||
/// - 닫기 버튼 및 사용자 정의 액션을 지원한다.
|
||||
class SuperportDialog extends StatelessWidget {
|
||||
const SuperportDialog({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.description,
|
||||
this.child = const SizedBox.shrink(),
|
||||
this.primaryAction,
|
||||
this.secondaryAction,
|
||||
this.mobileFullscreen = true,
|
||||
this.constraints,
|
||||
this.actions,
|
||||
this.contentPadding,
|
||||
this.header,
|
||||
this.footer,
|
||||
this.showCloseButton = true,
|
||||
this.onClose,
|
||||
this.scrollable = true,
|
||||
this.insetPadding,
|
||||
this.onSubmit,
|
||||
this.enableFocusTrap = true,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? description;
|
||||
final Widget child;
|
||||
final Widget? primaryAction;
|
||||
final Widget? secondaryAction;
|
||||
final bool mobileFullscreen;
|
||||
final BoxConstraints? constraints;
|
||||
final List<Widget>? actions;
|
||||
final EdgeInsetsGeometry? contentPadding;
|
||||
final Widget? header;
|
||||
final Widget? footer;
|
||||
final bool showCloseButton;
|
||||
final VoidCallback? onClose;
|
||||
final bool scrollable;
|
||||
final EdgeInsets? insetPadding;
|
||||
final FutureOr<void> Function()? onSubmit;
|
||||
final bool enableFocusTrap;
|
||||
|
||||
static Future<T?> show<T>({
|
||||
required BuildContext context,
|
||||
required SuperportDialog dialog,
|
||||
bool barrierDismissible = true,
|
||||
}) {
|
||||
return showDialog<T>(
|
||||
context: context,
|
||||
barrierDismissible: barrierDismissible,
|
||||
builder: (_) => dialog,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final theme = ShadTheme.of(context);
|
||||
final screenWidth = mediaQuery.size.width;
|
||||
final isMobile = screenWidth <= _kDialogMobileBreakpoint;
|
||||
|
||||
void handleClose() {
|
||||
if (onClose != null) {
|
||||
onClose!();
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).maybePop();
|
||||
}
|
||||
|
||||
final resolvedHeader =
|
||||
header ??
|
||||
_SuperportDialogHeader(
|
||||
title: title,
|
||||
description: description,
|
||||
showCloseButton: showCloseButton,
|
||||
onClose: handleClose,
|
||||
);
|
||||
final resolvedFooter = footer ?? _buildFooter(context);
|
||||
|
||||
final card = ShadCard(
|
||||
padding: EdgeInsets.zero,
|
||||
child: ClipRRect(
|
||||
borderRadius: theme.radius,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
resolvedHeader,
|
||||
Flexible(
|
||||
child: _DialogBody(
|
||||
padding: contentPadding ?? _kDialogBodyPadding,
|
||||
scrollable: scrollable,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
if (resolvedFooter != null) resolvedFooter,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final resolvedConstraints =
|
||||
constraints ??
|
||||
BoxConstraints(
|
||||
maxWidth: isMobile && mobileFullscreen
|
||||
? screenWidth
|
||||
: _kDialogMaxWidth,
|
||||
minWidth: isMobile && mobileFullscreen ? screenWidth : 360,
|
||||
);
|
||||
|
||||
final resolvedInset =
|
||||
insetPadding ??
|
||||
(isMobile && mobileFullscreen ? EdgeInsets.zero : _kDialogDesktopInset);
|
||||
|
||||
return Dialog(
|
||||
insetPadding: resolvedInset,
|
||||
backgroundColor: Colors.transparent,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
child: SafeArea(
|
||||
child: AnimatedPadding(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
curve: Curves.easeOut,
|
||||
padding: mediaQuery.viewInsets,
|
||||
child: ConstrainedBox(
|
||||
constraints: resolvedConstraints,
|
||||
child: DialogKeyboardShortcuts(
|
||||
onEscape: handleClose,
|
||||
onSubmit: onSubmit,
|
||||
enableFocusTrap: enableFocusTrap,
|
||||
child: card,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _buildFooter(BuildContext context) {
|
||||
if (actions != null) {
|
||||
final filtered = actions!.whereType<Widget>().toList();
|
||||
if (filtered.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return _DialogFooter(children: filtered);
|
||||
}
|
||||
|
||||
final fallback = <Widget>[
|
||||
if (secondaryAction != null) secondaryAction!,
|
||||
primaryAction ??
|
||||
ShadButton.ghost(
|
||||
onPressed: onClose ?? () => Navigator.of(context).maybePop(),
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
].whereType<Widget>().toList();
|
||||
|
||||
if (fallback.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return _DialogFooter(children: fallback);
|
||||
}
|
||||
}
|
||||
|
||||
class _DialogBody extends StatelessWidget {
|
||||
const _DialogBody({
|
||||
required this.child,
|
||||
required this.padding,
|
||||
required this.scrollable,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final bool scrollable;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final content = Padding(padding: padding, child: child);
|
||||
|
||||
if (!scrollable) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return SingleChildScrollView(child: content);
|
||||
}
|
||||
}
|
||||
|
||||
class _DialogFooter extends StatelessWidget {
|
||||
const _DialogFooter({required this.children});
|
||||
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: ShadTheme.of(context).colorScheme.muted,
|
||||
border: Border(
|
||||
top: BorderSide(color: ShadTheme.of(context).colorScheme.border),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
for (var i = 0; i < children.length; i++)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: i == 0 ? 0 : 12),
|
||||
child: children[i],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SuperportDialogHeader extends StatelessWidget {
|
||||
const _SuperportDialogHeader({
|
||||
required this.title,
|
||||
this.description,
|
||||
required this.showCloseButton,
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? description;
|
||||
final bool showCloseButton;
|
||||
final VoidCallback onClose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.card,
|
||||
border: Border(bottom: BorderSide(color: theme.colorScheme.border)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.h3),
|
||||
if (description != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(description!, style: theme.textTheme.muted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showCloseButton)
|
||||
IconButton(
|
||||
icon: const Icon(lucide.LucideIcons.x, size: 18),
|
||||
tooltip: '닫기',
|
||||
onPressed: onClose,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience wrapper around [SuperportDialog.show] to reduce boilerplate in pages.
|
||||
Future<T?> showSuperportDialog<T>({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
String? description,
|
||||
required Widget body,
|
||||
Widget? primaryAction,
|
||||
Widget? secondaryAction,
|
||||
List<Widget>? actions,
|
||||
bool mobileFullscreen = true,
|
||||
bool barrierDismissible = true,
|
||||
BoxConstraints? constraints,
|
||||
EdgeInsetsGeometry? contentPadding,
|
||||
bool scrollable = true,
|
||||
bool showCloseButton = true,
|
||||
VoidCallback? onClose,
|
||||
FutureOr<void> Function()? onSubmit,
|
||||
bool enableFocusTrap = true,
|
||||
}) {
|
||||
return showDialog<T>(
|
||||
return SuperportDialog.show<T>(
|
||||
context: context,
|
||||
barrierDismissible: barrierDismissible,
|
||||
builder: (dialogContext) {
|
||||
final theme = ShadTheme.of(dialogContext);
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.all(24),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ShadCard(
|
||||
title: Text(title, style: theme.textTheme.h3),
|
||||
description: description == null
|
||||
? null
|
||||
: Text(description, style: theme.textTheme.muted),
|
||||
footer: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: actions ?? <Widget>[
|
||||
ShadButton.ghost(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
dialog: SuperportDialog(
|
||||
title: title,
|
||||
description: description,
|
||||
primaryAction: primaryAction,
|
||||
secondaryAction: secondaryAction,
|
||||
actions: actions,
|
||||
constraints: constraints,
|
||||
mobileFullscreen: mobileFullscreen,
|
||||
contentPadding: contentPadding,
|
||||
scrollable: scrollable,
|
||||
showCloseButton: showCloseButton,
|
||||
onClose: onClose,
|
||||
onSubmit: onSubmit,
|
||||
enableFocusTrap: enableFocusTrap,
|
||||
child: body,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
@@ -5,54 +7,107 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
class SuperportTable extends StatelessWidget {
|
||||
const SuperportTable({
|
||||
super.key,
|
||||
required this.columns,
|
||||
required this.rows,
|
||||
required List<Widget> columns,
|
||||
required List<List<Widget>> rows,
|
||||
this.columnSpanExtent,
|
||||
this.rowHeight = 56,
|
||||
this.maxHeight,
|
||||
this.onRowTap,
|
||||
this.emptyLabel = '데이터가 없습니다.',
|
||||
});
|
||||
}) : _columns = columns,
|
||||
_rows = rows,
|
||||
_headerCells = null,
|
||||
_rowCells = null;
|
||||
|
||||
final List<Widget> columns;
|
||||
final List<List<Widget>> rows;
|
||||
const SuperportTable.fromCells({
|
||||
super.key,
|
||||
required List<ShadTableCell> header,
|
||||
required List<List<ShadTableCell>> rows,
|
||||
this.columnSpanExtent,
|
||||
this.rowHeight = 56,
|
||||
this.maxHeight,
|
||||
this.onRowTap,
|
||||
this.emptyLabel = '데이터가 없습니다.',
|
||||
}) : _columns = null,
|
||||
_rows = null,
|
||||
_headerCells = header,
|
||||
_rowCells = rows;
|
||||
|
||||
final List<Widget>? _columns;
|
||||
final List<List<Widget>>? _rows;
|
||||
final List<ShadTableCell>? _headerCells;
|
||||
final List<List<ShadTableCell>>? _rowCells;
|
||||
final TableSpanExtent? Function(int index)? columnSpanExtent;
|
||||
final double rowHeight;
|
||||
final double? maxHeight;
|
||||
final void Function(int index)? onRowTap;
|
||||
final String emptyLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
late final List<ShadTableCell> headerCells;
|
||||
late final List<List<ShadTableCell>> tableRows;
|
||||
|
||||
if (_rowCells case final rows?) {
|
||||
if (rows.isEmpty) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Center(
|
||||
child: Text(emptyLabel, style: theme.textTheme.muted),
|
||||
),
|
||||
child: Center(child: Text(emptyLabel, style: theme.textTheme.muted)),
|
||||
);
|
||||
}
|
||||
|
||||
final tableRows = [
|
||||
for (final row in rows)
|
||||
row
|
||||
.map(
|
||||
(cell) => cell is ShadTableCell ? cell : ShadTableCell(child: cell),
|
||||
)
|
||||
.toList(),
|
||||
];
|
||||
|
||||
return ShadTable.list(
|
||||
header: columns
|
||||
final header = _headerCells;
|
||||
if (header == null) {
|
||||
throw StateError('header cells must not be null when using fromCells');
|
||||
}
|
||||
headerCells = header;
|
||||
tableRows = rows;
|
||||
} else {
|
||||
final rows = _rows;
|
||||
if (rows == null || rows.isEmpty) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Center(child: Text(emptyLabel, style: theme.textTheme.muted)),
|
||||
);
|
||||
}
|
||||
headerCells = _columns!
|
||||
.map(
|
||||
(cell) => cell is ShadTableCell
|
||||
? cell
|
||||
: ShadTableCell.header(child: cell),
|
||||
)
|
||||
.toList();
|
||||
tableRows = [
|
||||
for (final row in rows)
|
||||
row
|
||||
.map(
|
||||
(cell) =>
|
||||
cell is ShadTableCell ? cell : ShadTableCell(child: cell),
|
||||
)
|
||||
.toList(),
|
||||
];
|
||||
}
|
||||
|
||||
final estimatedHeight = (tableRows.length + 1) * rowHeight;
|
||||
final minHeight = rowHeight * 2;
|
||||
final effectiveHeight = math.max(
|
||||
minHeight,
|
||||
maxHeight == null
|
||||
? estimatedHeight
|
||||
: math.min(estimatedHeight, maxHeight!),
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
height: effectiveHeight,
|
||||
child: ShadTable.list(
|
||||
header: headerCells,
|
||||
columnSpanExtent: columnSpanExtent,
|
||||
rowSpanExtent: (_) => FixedTableSpanExtent(rowHeight),
|
||||
onRowTap: onRowTap,
|
||||
primary: false,
|
||||
children: tableRows,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import flutter_secure_storage_macos
|
||||
import path_provider_foundation
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
}
|
||||
|
||||
60
pubspec.lock
60
pubspec.lock
@@ -155,6 +155,54 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.2.4"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
flutter_secure_storage_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_macos
|
||||
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_platform_interface
|
||||
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_secure_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
flutter_shaders:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -233,10 +281,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
version: "0.6.7"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -554,6 +602,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.14.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -44,6 +44,7 @@ dependencies:
|
||||
dio: ^5.5.0+1
|
||||
get_it: ^7.7.0
|
||||
flutter_dotenv: ^5.1.0
|
||||
flutter_secure_storage: ^9.2.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
73
test/core/permissions/permission_manager_test.dart
Normal file
73
test/core/permissions/permission_manager_test.dart
Normal file
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:superport_v2/core/permissions/permission_manager.dart';
|
||||
|
||||
void main() {
|
||||
group('PermissionManager', () {
|
||||
test('falls back to environment permissions when no override', () {
|
||||
final manager = PermissionManager();
|
||||
final allowed = manager.can('/any', PermissionAction.view);
|
||||
expect(allowed, isTrue);
|
||||
});
|
||||
|
||||
test('respects overrides', () {
|
||||
final manager = PermissionManager(
|
||||
overrides: {
|
||||
'/inventory/inbound': {
|
||||
PermissionAction.view,
|
||||
PermissionAction.create,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(manager.can('/inventory/inbound', PermissionAction.view), isTrue);
|
||||
expect(
|
||||
manager.can('/inventory/inbound', PermissionAction.create),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
manager.can('/inventory/inbound', PermissionAction.delete),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('PermissionGate hides child when unauthorized', (tester) async {
|
||||
final manager = PermissionManager(overrides: {'/resource': {}});
|
||||
await tester.pumpWidget(
|
||||
PermissionScope(
|
||||
manager: manager,
|
||||
child: const Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: PermissionGate(
|
||||
resource: '/resource',
|
||||
action: PermissionAction.view,
|
||||
child: Text('secret'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('secret'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('PermissionGate shows fallback when provided', (tester) async {
|
||||
final manager = PermissionManager(overrides: {'/resource': {}});
|
||||
await tester.pumpWidget(
|
||||
PermissionScope(
|
||||
manager: manager,
|
||||
child: const Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: PermissionGate(
|
||||
resource: '/resource',
|
||||
action: PermissionAction.view,
|
||||
fallback: Text('fallback'),
|
||||
child: Text('secret'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('fallback'), findsOneWidget);
|
||||
expect(find.text('secret'), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -55,6 +55,5 @@ void main() {
|
||||
repository = _MockApprovalRepository();
|
||||
GetIt.I.registerLazySingleton<ApprovalRepository>(() => repository);
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
@@ -138,6 +138,48 @@ void main() {
|
||||
expect(find.text('고객사명을 입력하세요.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('우편번호를 수동 입력하면 검색 안내를 노출한다', (tester) async {
|
||||
when(
|
||||
() => repository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
isPartner: any(named: 'isPartner'),
|
||||
isGeneral: any(named: 'isGeneral'),
|
||||
isActive: any(named: 'isActive'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => PaginatedResult<Customer>(
|
||||
items: const [],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildApp(CustomerPage(routeUri: Uri(path: '/masters/customers'))),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('신규 등록'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final fields = find.descendant(
|
||||
of: find.byType(Dialog),
|
||||
matching: find.byType(EditableText),
|
||||
);
|
||||
|
||||
await tester.enterText(fields.at(0), 'C-200');
|
||||
await tester.enterText(fields.at(1), '검색 필요 고객');
|
||||
await tester.enterText(fields.at(4), '06000');
|
||||
|
||||
await tester.tap(find.text('등록'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('우편번호 검색으로 주소를 선택하세요.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('신규 등록 성공 시 repository.create 호출', (tester) async {
|
||||
var listCallCount = 0;
|
||||
when(
|
||||
|
||||
@@ -139,7 +139,7 @@ void main() {
|
||||
await tester.pumpWidget(_buildApp(const GroupPermissionPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('대시보드'), findsOneWidget);
|
||||
expect(find.text('대시보드'), findsWidgets);
|
||||
expect(find.text('관리자'), findsOneWidget);
|
||||
});
|
||||
|
||||
@@ -278,7 +278,7 @@ void main() {
|
||||
expect(capturedInput?.canCreate, isTrue);
|
||||
expect(capturedInput?.canUpdate, isTrue);
|
||||
expect(find.byType(Dialog), findsNothing);
|
||||
expect(find.text('대시보드'), findsOneWidget);
|
||||
expect(find.text('대시보드'), findsWidgets);
|
||||
verify(() => permissionRepository.create(any())).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,7 +49,9 @@ void main() {
|
||||
testWidgets('플래그 Off 시 스펙 문서 화면을 노출한다', (tester) async {
|
||||
dotenv.testLoad(fileInput: 'FEATURE_PRODUCTS_ENABLED=false\n');
|
||||
|
||||
await tester.pumpWidget(_buildApp(const ProductPage()));
|
||||
await tester.pumpWidget(
|
||||
_buildApp(ProductPage(routeUri: Uri(path: '/masters/products'))),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('장비 모델(제품) 관리'), findsOneWidget);
|
||||
@@ -134,7 +136,9 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_buildApp(const ProductPage()));
|
||||
await tester.pumpWidget(
|
||||
_buildApp(ProductPage(routeUri: Uri(path: '/masters/products'))),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('P-001'), findsOneWidget);
|
||||
@@ -169,7 +173,9 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_buildApp(const ProductPage()));
|
||||
await tester.pumpWidget(
|
||||
_buildApp(ProductPage(routeUri: Uri(path: '/masters/products'))),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('신규 등록'));
|
||||
@@ -243,7 +249,9 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
await tester.pumpWidget(_buildApp(const ProductPage()));
|
||||
await tester.pumpWidget(
|
||||
_buildApp(ProductPage(routeUri: Uri(path: '/masters/products'))),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('신규 등록'));
|
||||
|
||||
@@ -55,7 +55,6 @@ void main() {
|
||||
group('플래그 On', () {
|
||||
late _MockUserRepository userRepository;
|
||||
late _MockGroupRepository groupRepository;
|
||||
|
||||
setUp(() {
|
||||
dotenv.testLoad(fileInput: 'FEATURE_USERS_ENABLED=true\n');
|
||||
userRepository = _MockUserRepository();
|
||||
@@ -153,6 +152,14 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('신규 등록 성공', (tester) async {
|
||||
final view = tester.view;
|
||||
view.physicalSize = const Size(1280, 800);
|
||||
view.devicePixelRatio = 1.0;
|
||||
addTearDown(() {
|
||||
view.resetPhysicalSize();
|
||||
view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
var listCallCount = 0;
|
||||
when(
|
||||
() => userRepository.list(
|
||||
@@ -214,9 +221,21 @@ void main() {
|
||||
await tester.enterText(editableTexts.at(0), 'A010');
|
||||
await tester.enterText(editableTexts.at(1), '신규 사용자');
|
||||
|
||||
await tester.tap(find.text('그룹을 선택하세요'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('관리자'));
|
||||
final selectFinder = find.descendant(
|
||||
of: dialog,
|
||||
matching: find.byType(ShadSelect<int?>),
|
||||
);
|
||||
final selectElement = tester.element(selectFinder);
|
||||
final renderBox = selectElement.renderObject as RenderBox;
|
||||
final globalCenter = renderBox.localToGlobal(
|
||||
renderBox.size.center(Offset.zero),
|
||||
);
|
||||
await tester.tapAt(globalCenter);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
final adminOption = find.text('관리자', skipOffstage: false);
|
||||
expect(adminOption, findsWidgets);
|
||||
await tester.tap(adminOption.first, warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('등록'));
|
||||
|
||||
@@ -41,7 +41,9 @@ void main() {
|
||||
testWidgets('FEATURE_VENDORS_ENABLED=false 이면 스펙 페이지를 노출한다', (tester) async {
|
||||
dotenv.testLoad(fileInput: 'FEATURE_VENDORS_ENABLED=false\n');
|
||||
|
||||
await tester.pumpWidget(_buildApp(const VendorPage()));
|
||||
await tester.pumpWidget(
|
||||
_buildApp(VendorPage(routeUri: Uri(path: '/masters/vendors'))),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('제조사(벤더) 관리'), findsOneWidget);
|
||||
@@ -71,7 +73,9 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_buildApp(const VendorPage()));
|
||||
await tester.pumpWidget(
|
||||
_buildApp(VendorPage(routeUri: Uri(path: '/masters/vendors'))),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('V-001'), findsOneWidget);
|
||||
@@ -101,7 +105,9 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_buildApp(const VendorPage()));
|
||||
await tester.pumpWidget(
|
||||
_buildApp(VendorPage(routeUri: Uri(path: '/masters/vendors'))),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('신규 등록'));
|
||||
@@ -155,7 +161,9 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
await tester.pumpWidget(_buildApp(const VendorPage()));
|
||||
await tester.pumpWidget(
|
||||
_buildApp(VendorPage(routeUri: Uri(path: '/masters/vendors'))),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('신규 등록'));
|
||||
|
||||
@@ -9,11 +9,16 @@ import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart';
|
||||
import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart';
|
||||
import 'package:superport_v2/features/masters/warehouse/presentation/pages/warehouse_page.dart';
|
||||
import 'package:superport_v2/features/util/postal_search/domain/entities/postal_code.dart';
|
||||
import 'package:superport_v2/features/util/postal_search/domain/repositories/postal_search_repository.dart';
|
||||
|
||||
class _MockWarehouseRepository extends Mock implements WarehouseRepository {}
|
||||
|
||||
class _FakeWarehouseInput extends Fake implements WarehouseInput {}
|
||||
|
||||
class _MockPostalSearchRepository extends Mock
|
||||
implements PostalSearchRepository {}
|
||||
|
||||
Widget _buildApp(Widget child) {
|
||||
return MaterialApp(
|
||||
home: ShadTheme(
|
||||
@@ -41,7 +46,9 @@ void main() {
|
||||
testWidgets('플래그 Off 시 스펙 화면', (tester) async {
|
||||
dotenv.testLoad(fileInput: 'FEATURE_WAREHOUSES_ENABLED=false\n');
|
||||
|
||||
await tester.pumpWidget(_buildApp(const WarehousePage()));
|
||||
await tester.pumpWidget(
|
||||
_buildApp(WarehousePage(routeUri: Uri(path: '/masters/warehouses'))),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('입고지(창고) 관리'), findsOneWidget);
|
||||
@@ -50,11 +57,32 @@ void main() {
|
||||
|
||||
group('플래그 On', () {
|
||||
late _MockWarehouseRepository repository;
|
||||
late _MockPostalSearchRepository postalRepository;
|
||||
|
||||
setUp(() {
|
||||
dotenv.testLoad(fileInput: 'FEATURE_WAREHOUSES_ENABLED=true\n');
|
||||
repository = _MockWarehouseRepository();
|
||||
postalRepository = _MockPostalSearchRepository();
|
||||
GetIt.I.registerLazySingleton<WarehouseRepository>(() => repository);
|
||||
GetIt.I.registerLazySingleton<PostalSearchRepository>(
|
||||
() => postalRepository,
|
||||
);
|
||||
when(
|
||||
() => postalRepository.search(
|
||||
keyword: any(named: 'keyword'),
|
||||
limit: any(named: 'limit'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => [
|
||||
PostalCode(
|
||||
zipcode: '06000',
|
||||
sido: '서울특별시',
|
||||
sigungu: '강남구',
|
||||
roadName: '테헤란로',
|
||||
buildingMainNo: 100,
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('목록 조회 후 테이블 표시', (tester) async {
|
||||
@@ -81,7 +109,9 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_buildApp(const WarehousePage()));
|
||||
await tester.pumpWidget(
|
||||
_buildApp(WarehousePage(routeUri: Uri(path: '/masters/warehouses'))),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('WH-001'), findsOneWidget);
|
||||
@@ -108,7 +138,9 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_buildApp(const WarehousePage()));
|
||||
await tester.pumpWidget(
|
||||
_buildApp(WarehousePage(routeUri: Uri(path: '/masters/warehouses'))),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('신규 등록'));
|
||||
@@ -164,7 +196,9 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
await tester.pumpWidget(_buildApp(const WarehousePage()));
|
||||
await tester.pumpWidget(
|
||||
_buildApp(WarehousePage(routeUri: Uri(path: '/masters/warehouses'))),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('신규 등록'));
|
||||
@@ -177,14 +211,31 @@ void main() {
|
||||
|
||||
await tester.enterText(fields.at(0), 'WH-100');
|
||||
await tester.enterText(fields.at(1), '신규 창고');
|
||||
await tester.enterText(fields.at(2), '12345');
|
||||
await tester.enterText(fields.at(3), '주소');
|
||||
await tester.enterText(fields.at(2), '06000');
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(Dialog),
|
||||
matching: find.widgetWithText(ShadButton, '검색'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('06000').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final updatedFields = find.descendant(
|
||||
of: find.byType(Dialog),
|
||||
matching: find.byType(EditableText),
|
||||
);
|
||||
|
||||
await tester.enterText(updatedFields.at(3), '주소');
|
||||
|
||||
await tester.tap(find.text('등록'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(capturedInput, isNotNull);
|
||||
expect(capturedInput?.warehouseCode, 'WH-100');
|
||||
expect(capturedInput?.zipcode, '06000');
|
||||
expect(find.byType(Dialog), findsNothing);
|
||||
expect(find.text('WH-100'), findsOneWidget);
|
||||
verify(() => repository.create(any())).called(1);
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import 'package:superport_v2/features/util/postal_search/presentation/models/postal_search_result.dart';
|
||||
import 'package:superport_v2/features/util/postal_search/presentation/widgets/postal_search_dialog.dart';
|
||||
|
||||
class _PostalSearchHarness extends StatefulWidget {
|
||||
const _PostalSearchHarness({required this.fetcher, this.initialKeyword});
|
||||
|
||||
final PostalSearchFetcher fetcher;
|
||||
final String? initialKeyword;
|
||||
|
||||
@override
|
||||
State<_PostalSearchHarness> createState() => _PostalSearchHarnessState();
|
||||
}
|
||||
|
||||
class _PostalSearchHarnessState extends State<_PostalSearchHarness> {
|
||||
PostalSearchResult? _selection;
|
||||
bool _dialogScheduled = false;
|
||||
|
||||
void _ensureDialogShown(BuildContext innerContext) {
|
||||
if (_dialogScheduled) {
|
||||
return;
|
||||
}
|
||||
_dialogScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
final result = await showPostalSearchDialog(
|
||||
innerContext,
|
||||
fetcher: widget.fetcher,
|
||||
initialKeyword: widget.initialKeyword,
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_selection = result;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
home: Builder(
|
||||
builder: (innerContext) {
|
||||
return ShadTheme(
|
||||
data: ShadThemeData(
|
||||
colorScheme: const ShadSlateColorScheme.light(),
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
child: Builder(
|
||||
builder: (themeContext) {
|
||||
_ensureDialogShown(themeContext);
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Text(
|
||||
_selection?.zipcode ?? '선택 없음',
|
||||
key: const Key('selected_zipcode'),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('검색 전 안내 문구를 노출하고 fetcher를 호출하지 않는다', (tester) async {
|
||||
var called = false;
|
||||
|
||||
await tester.pumpWidget(
|
||||
_PostalSearchHarness(
|
||||
fetcher: (keyword) async {
|
||||
called = true;
|
||||
return [];
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('검색어를 입력한 뒤 엔터 또는 검색 버튼을 눌러 주세요.'), findsOneWidget);
|
||||
expect(called, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('검색 실행 후 결과를 선택하면 선택 정보가 반환된다', (tester) async {
|
||||
var receivedKeyword = '';
|
||||
|
||||
await tester.pumpWidget(
|
||||
_PostalSearchHarness(
|
||||
fetcher: (keyword) async {
|
||||
receivedKeyword = keyword;
|
||||
return [
|
||||
PostalSearchResult(
|
||||
zipcode: '06000',
|
||||
sido: '서울특별시',
|
||||
sigungu: '강남구',
|
||||
roadName: '언주로',
|
||||
buildingNumber: '100',
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final inputFinder = find.byType(EditableText);
|
||||
expect(inputFinder, findsOneWidget);
|
||||
|
||||
await tester.enterText(inputFinder, '언주로');
|
||||
await tester.tap(find.text('검색'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(receivedKeyword, '언주로');
|
||||
expect(find.text('검색 결과 1건'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('06000'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final selectedFinder = find.byKey(const Key('selected_zipcode'));
|
||||
expect(selectedFinder, findsOneWidget);
|
||||
final selectedText = tester.widget<Text>(selectedFinder);
|
||||
expect(selectedText.data, '06000');
|
||||
});
|
||||
|
||||
testWidgets('initialKeyword가 주어지면 자동으로 검색을 수행한다', (tester) async {
|
||||
String? receivedKeyword;
|
||||
|
||||
await tester.pumpWidget(
|
||||
_PostalSearchHarness(
|
||||
fetcher: (keyword) async {
|
||||
receivedKeyword = keyword;
|
||||
return [];
|
||||
},
|
||||
initialKeyword: '06236',
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(receivedKeyword, '06236');
|
||||
|
||||
await tester.tap(find.text('닫기'));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
}
|
||||
17
test/helpers/test_app.dart
Normal file
17
test/helpers/test_app.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import 'package:superport_v2/core/permissions/permission_manager.dart';
|
||||
import 'package:superport_v2/core/theme/superport_shad_theme.dart';
|
||||
|
||||
Widget buildTestApp(Widget child, {PermissionManager? permissionManager}) {
|
||||
return PermissionScope(
|
||||
manager: permissionManager ?? PermissionManager(),
|
||||
child: ShadApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: SuperportShadTheme.light(),
|
||||
darkTheme: SuperportShadTheme.dark(),
|
||||
home: Scaffold(body: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
175
test/navigation/navigation_flow_test.dart
Normal file
175
test/navigation/navigation_flow_test.dart
Normal file
@@ -0,0 +1,175 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import 'package:superport_v2/core/config/environment.dart';
|
||||
import 'package:superport_v2/core/constants/app_sections.dart';
|
||||
import 'package:superport_v2/features/login/presentation/pages/login_page.dart';
|
||||
import 'package:superport_v2/core/theme/superport_shad_theme.dart';
|
||||
import 'package:superport_v2/core/permissions/permission_manager.dart';
|
||||
|
||||
GoRouter _createTestRouter() {
|
||||
return GoRouter(
|
||||
initialLocation: loginRoutePath,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: loginRoutePath,
|
||||
builder: (context, state) => const LoginPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: dashboardRoutePath,
|
||||
builder: (context, state) => const _TestDashboardPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/inventory/inbound',
|
||||
builder: (context, state) => const _PlaceholderPage(title: '입고 화면'),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/inventory/outbound',
|
||||
builder: (context, state) => const _PlaceholderPage(title: '출고 화면'),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/inventory/rental',
|
||||
builder: (context, state) => const _PlaceholderPage(title: '대여 화면'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _TestApp extends StatelessWidget {
|
||||
const _TestApp({required this.router});
|
||||
|
||||
final GoRouter router;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PermissionScope(
|
||||
manager: PermissionManager(),
|
||||
child: ShadApp.router(
|
||||
routerConfig: router,
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: SuperportShadTheme.light(),
|
||||
darkTheme: SuperportShadTheme.dark(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TestDashboardPage extends StatelessWidget {
|
||||
const _TestDashboardPage();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('테스트 대시보드'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: '로그아웃',
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: () => context.go(loginRoutePath),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => context.go('/inventory/inbound'),
|
||||
child: const Text('입고로 이동'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.go('/inventory/outbound'),
|
||||
child: const Text('출고로 이동'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.go('/inventory/rental'),
|
||||
child: const Text('대여로 이동'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaceholderPage extends StatelessWidget {
|
||||
const _PlaceholderPage({required this.title});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(title)),
|
||||
body: Center(
|
||||
child: TextButton(
|
||||
onPressed: () => context.go(dashboardRoutePath),
|
||||
child: const Text('대시보드로 돌아가기'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(() async {
|
||||
await Environment.initialize();
|
||||
});
|
||||
|
||||
Finder editableTextAt(int index) => find.byType(EditableText).at(index);
|
||||
|
||||
testWidgets('사용자가 로그인 후 주요 화면을 탐색할 수 있다', (tester) async {
|
||||
final view = tester.view;
|
||||
view.physicalSize = const Size(1080, 720);
|
||||
view.devicePixelRatio = 1.0;
|
||||
addTearDown(() {
|
||||
view.resetPhysicalSize();
|
||||
view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
final router = _createTestRouter();
|
||||
await tester.pumpWidget(_TestApp(router: router));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Superport v2 로그인'), findsOneWidget);
|
||||
|
||||
await tester.enterText(editableTextAt(0), 'tester');
|
||||
await tester.enterText(editableTextAt(1), 'password123');
|
||||
await tester.tap(find.widgetWithText(ShadButton, '로그인'));
|
||||
await tester.pump(const Duration(milliseconds: 650));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('테스트 대시보드'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.widgetWithText(TextButton, '입고로 이동'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('입고 화면'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.widgetWithText(TextButton, '대시보드로 돌아가기'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('테스트 대시보드'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.widgetWithText(TextButton, '출고로 이동'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('출고 화면'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.widgetWithText(TextButton, '대시보드로 돌아가기'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.widgetWithText(TextButton, '대여로 이동'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('대여 화면'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.widgetWithText(TextButton, '대시보드로 돌아가기'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byTooltip('로그아웃'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Superport v2 로그인'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
54
test/widgets/dialog_keyboard_shortcuts_test.dart
Normal file
54
test/widgets/dialog_keyboard_shortcuts_test.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:superport_v2/widgets/components/keyboard_shortcuts.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('DialogKeyboardShortcuts handles escape and enter', (
|
||||
tester,
|
||||
) async {
|
||||
var escape = 0;
|
||||
var submit = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: DialogKeyboardShortcuts(
|
||||
onEscape: () => escape++,
|
||||
onSubmit: () => submit++,
|
||||
child: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pump();
|
||||
expect(escape, 1);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
expect(submit, 1);
|
||||
});
|
||||
|
||||
testWidgets('DialogKeyboardShortcuts does not submit from multiline input', (
|
||||
tester,
|
||||
) async {
|
||||
var submit = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: DialogKeyboardShortcuts(
|
||||
onSubmit: () => submit++,
|
||||
child: const Material(child: TextField(maxLines: 5)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byType(TextField));
|
||||
await tester.pump();
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
|
||||
expect(submit, 0);
|
||||
});
|
||||
}
|
||||
9
tool/format.sh
Executable file
9
tool/format.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# 프로젝트 전체를 포맷합니다. 추가 인자 없이 실행하면 현재 디렉터리 기준으로 진행됩니다.
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$DIR"
|
||||
|
||||
dart format .
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
Reference in New Issue
Block a user