전역 구조 리팩터링 및 테스트 확장
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 late final bool isProduction;
|
||||||
|
|
||||||
|
static final Map<String, Set<String>> _permissions = {};
|
||||||
|
|
||||||
/// 환경 초기화
|
/// 환경 초기화
|
||||||
///
|
///
|
||||||
/// - 기본 환경은 development이며, `ENV` dart-define 으로 변경 가능
|
/// - 기본 환경은 development이며, `ENV` dart-define 으로 변경 가능
|
||||||
/// - 해당 환경의 .env 파일을 로드하고 핵심 값을 추출한다.
|
/// - 해당 환경의 .env 파일을 로드하고 핵심 값을 추출한다.
|
||||||
static Future<void> initialize() async {
|
static Future<void> initialize() async {
|
||||||
const envFromDefine = String.fromEnvironment('ENV', defaultValue: 'development');
|
const envFromDefine = String.fromEnvironment(
|
||||||
|
'ENV',
|
||||||
|
defaultValue: 'development',
|
||||||
|
);
|
||||||
envName = envFromDefine.toLowerCase();
|
envName = envFromDefine.toLowerCase();
|
||||||
isProduction = envName == 'production';
|
isProduction = envName == 'production';
|
||||||
|
|
||||||
@@ -46,6 +51,7 @@ class Environment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
baseUrl = dotenv.maybeGet('API_BASE_URL') ?? 'http://localhost:8080';
|
baseUrl = dotenv.maybeGet('API_BASE_URL') ?? 'http://localhost:8080';
|
||||||
|
_loadPermissions();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 기능 플래그 조회 (기본 false)
|
/// 기능 플래그 조회 (기본 false)
|
||||||
@@ -67,4 +73,32 @@ class Environment {
|
|||||||
return defaultValue;
|
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:flutter/widgets.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||||
|
|
||||||
class AppPageDescriptor {
|
class AppPageDescriptor {
|
||||||
const AppPageDescriptor({
|
const AppPageDescriptor({
|
||||||
@@ -32,7 +32,7 @@ const appSections = <AppSectionDescriptor>[
|
|||||||
AppPageDescriptor(
|
AppPageDescriptor(
|
||||||
path: dashboardRoutePath,
|
path: dashboardRoutePath,
|
||||||
label: '대시보드',
|
label: '대시보드',
|
||||||
icon: LucideIcons.layoutDashboard,
|
icon: lucide.LucideIcons.layoutDashboard,
|
||||||
summary: '오늘 입고/출고, 결재 대기, 최근 트랜잭션을 한 화면에서 확인합니다.',
|
summary: '오늘 입고/출고, 결재 대기, 최근 트랜잭션을 한 화면에서 확인합니다.',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -43,19 +43,19 @@ const appSections = <AppSectionDescriptor>[
|
|||||||
AppPageDescriptor(
|
AppPageDescriptor(
|
||||||
path: '/inventory/inbound',
|
path: '/inventory/inbound',
|
||||||
label: '입고',
|
label: '입고',
|
||||||
icon: LucideIcons.packagePlus,
|
icon: lucide.LucideIcons.packagePlus,
|
||||||
summary: '입고 처리 기본정보와 라인 품목을 등록하고 검토합니다.',
|
summary: '입고 처리 기본정보와 라인 품목을 등록하고 검토합니다.',
|
||||||
),
|
),
|
||||||
AppPageDescriptor(
|
AppPageDescriptor(
|
||||||
path: '/inventory/outbound',
|
path: '/inventory/outbound',
|
||||||
label: '출고',
|
label: '출고',
|
||||||
icon: LucideIcons.packageMinus,
|
icon: lucide.LucideIcons.packageMinus,
|
||||||
summary: '출고 품목, 고객사 연결, 상태 변경을 관리합니다.',
|
summary: '출고 품목, 고객사 연결, 상태 변경을 관리합니다.',
|
||||||
),
|
),
|
||||||
AppPageDescriptor(
|
AppPageDescriptor(
|
||||||
path: '/inventory/rental',
|
path: '/inventory/rental',
|
||||||
label: '대여',
|
label: '대여',
|
||||||
icon: LucideIcons.handshake,
|
icon: lucide.LucideIcons.handshake,
|
||||||
summary: '대여/반납 구분과 반납예정일을 포함한 대여 흐름입니다.',
|
summary: '대여/반납 구분과 반납예정일을 포함한 대여 흐름입니다.',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -66,49 +66,49 @@ const appSections = <AppSectionDescriptor>[
|
|||||||
AppPageDescriptor(
|
AppPageDescriptor(
|
||||||
path: '/masters/vendors',
|
path: '/masters/vendors',
|
||||||
label: '제조사 관리',
|
label: '제조사 관리',
|
||||||
icon: LucideIcons.factory,
|
icon: lucide.LucideIcons.factory,
|
||||||
summary: '벤더코드, 명칭, 사용여부 등을 유지합니다.',
|
summary: '벤더코드, 명칭, 사용여부 등을 유지합니다.',
|
||||||
),
|
),
|
||||||
AppPageDescriptor(
|
AppPageDescriptor(
|
||||||
path: '/masters/products',
|
path: '/masters/products',
|
||||||
label: '장비 모델 관리',
|
label: '장비 모델 관리',
|
||||||
icon: LucideIcons.box,
|
icon: lucide.LucideIcons.box,
|
||||||
summary: '제품코드, 제조사, 단위 정보를 관리합니다.',
|
summary: '제품코드, 제조사, 단위 정보를 관리합니다.',
|
||||||
),
|
),
|
||||||
AppPageDescriptor(
|
AppPageDescriptor(
|
||||||
path: '/masters/warehouses',
|
path: '/masters/warehouses',
|
||||||
label: '입고지 관리',
|
label: '입고지 관리',
|
||||||
icon: LucideIcons.warehouse,
|
icon: lucide.LucideIcons.warehouse,
|
||||||
summary: '창고 주소와 사용여부를 설정합니다.',
|
summary: '창고 주소와 사용여부를 설정합니다.',
|
||||||
),
|
),
|
||||||
AppPageDescriptor(
|
AppPageDescriptor(
|
||||||
path: '/masters/customers',
|
path: '/masters/customers',
|
||||||
label: '회사 관리',
|
label: '회사 관리',
|
||||||
icon: LucideIcons.building,
|
icon: lucide.LucideIcons.building,
|
||||||
summary: '고객사 연락처와 주소 정보를 관리합니다.',
|
summary: '고객사 연락처와 주소 정보를 관리합니다.',
|
||||||
),
|
),
|
||||||
AppPageDescriptor(
|
AppPageDescriptor(
|
||||||
path: '/masters/users',
|
path: '/masters/users',
|
||||||
label: '사용자 관리',
|
label: '사용자 관리',
|
||||||
icon: LucideIcons.users,
|
icon: lucide.LucideIcons.users,
|
||||||
summary: '사번, 그룹, 사용여부를 관리합니다.',
|
summary: '사번, 그룹, 사용여부를 관리합니다.',
|
||||||
),
|
),
|
||||||
AppPageDescriptor(
|
AppPageDescriptor(
|
||||||
path: '/masters/groups',
|
path: '/masters/groups',
|
||||||
label: '그룹 관리',
|
label: '그룹 관리',
|
||||||
icon: LucideIcons.layers,
|
icon: lucide.LucideIcons.layers,
|
||||||
summary: '권한 그룹과 설명, 기본여부를 정의합니다.',
|
summary: '권한 그룹과 설명, 기본여부를 정의합니다.',
|
||||||
),
|
),
|
||||||
AppPageDescriptor(
|
AppPageDescriptor(
|
||||||
path: '/masters/menus',
|
path: '/masters/menus',
|
||||||
label: '메뉴 관리',
|
label: '메뉴 관리',
|
||||||
icon: LucideIcons.listTree,
|
icon: lucide.LucideIcons.listTree,
|
||||||
summary: '메뉴 계층과 경로, 노출 순서를 구성합니다.',
|
summary: '메뉴 계층과 경로, 노출 순서를 구성합니다.',
|
||||||
),
|
),
|
||||||
AppPageDescriptor(
|
AppPageDescriptor(
|
||||||
path: '/masters/group-permissions',
|
path: '/masters/group-permissions',
|
||||||
label: '그룹 메뉴 권한',
|
label: '그룹 메뉴 권한',
|
||||||
icon: LucideIcons.shieldCheck,
|
icon: lucide.LucideIcons.shieldCheck,
|
||||||
summary: '그룹별 메뉴 CRUD 권한을 설정합니다.',
|
summary: '그룹별 메뉴 CRUD 권한을 설정합니다.',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -119,25 +119,25 @@ const appSections = <AppSectionDescriptor>[
|
|||||||
AppPageDescriptor(
|
AppPageDescriptor(
|
||||||
path: '/approvals/requests',
|
path: '/approvals/requests',
|
||||||
label: '결재 관리',
|
label: '결재 관리',
|
||||||
icon: LucideIcons.fileCheck,
|
icon: lucide.LucideIcons.fileCheck,
|
||||||
summary: '결재 번호, 상태, 상신자를 관리합니다.',
|
summary: '결재 번호, 상태, 상신자를 관리합니다.',
|
||||||
),
|
),
|
||||||
AppPageDescriptor(
|
AppPageDescriptor(
|
||||||
path: '/approvals/steps',
|
path: '/approvals/steps',
|
||||||
label: '결재 단계',
|
label: '결재 단계',
|
||||||
icon: LucideIcons.workflow,
|
icon: lucide.LucideIcons.workflow,
|
||||||
summary: '단계 순서와 승인자 할당을 설정합니다.',
|
summary: '단계 순서와 승인자 할당을 설정합니다.',
|
||||||
),
|
),
|
||||||
AppPageDescriptor(
|
AppPageDescriptor(
|
||||||
path: '/approvals/history',
|
path: '/approvals/history',
|
||||||
label: '결재 이력',
|
label: '결재 이력',
|
||||||
icon: LucideIcons.history,
|
icon: lucide.LucideIcons.history,
|
||||||
summary: '결재 단계별 변경 이력을 조회합니다.',
|
summary: '결재 단계별 변경 이력을 조회합니다.',
|
||||||
),
|
),
|
||||||
AppPageDescriptor(
|
AppPageDescriptor(
|
||||||
path: '/approvals/templates',
|
path: '/approvals/templates',
|
||||||
label: '결재 템플릿',
|
label: '결재 템플릿',
|
||||||
icon: LucideIcons.fileSpreadsheet,
|
icon: lucide.LucideIcons.fileSpreadsheet,
|
||||||
summary: '반복되는 결재 흐름을 템플릿으로 관리합니다.',
|
summary: '반복되는 결재 흐름을 템플릿으로 관리합니다.',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -148,7 +148,7 @@ const appSections = <AppSectionDescriptor>[
|
|||||||
AppPageDescriptor(
|
AppPageDescriptor(
|
||||||
path: '/utilities/postal-search',
|
path: '/utilities/postal-search',
|
||||||
label: '우편번호 검색',
|
label: '우편번호 검색',
|
||||||
icon: LucideIcons.search,
|
icon: lucide.LucideIcons.search,
|
||||||
summary: '모달 기반 우편번호 검색 도구입니다.',
|
summary: '모달 기반 우편번호 검색 도구입니다.',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -159,7 +159,7 @@ const appSections = <AppSectionDescriptor>[
|
|||||||
AppPageDescriptor(
|
AppPageDescriptor(
|
||||||
path: '/reports',
|
path: '/reports',
|
||||||
label: '보고서',
|
label: '보고서',
|
||||||
icon: LucideIcons.fileDown,
|
icon: lucide.LucideIcons.fileDown,
|
||||||
summary: '조건 필터와 PDF/XLSX 다운로드 기능입니다.',
|
summary: '조건 필터와 PDF/XLSX 다운로드 기능입니다.',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,18 +2,23 @@
|
|||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import 'api_error.dart';
|
||||||
|
|
||||||
/// 공통 API 클라이언트 (Dio 래퍼)
|
/// 공통 API 클라이언트 (Dio 래퍼)
|
||||||
/// - 모든 HTTP 호출은 이 클래스를 통해 이루어진다.
|
/// - 모든 HTTP 호출은 이 클래스를 통해 이루어진다.
|
||||||
/// - BaseURL/타임아웃/인증/로깅/에러 처리 등을 중앙집중화한다.
|
/// - BaseURL/타임아웃/인증/로깅/에러 처리 등을 중앙집중화한다.
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
|
ApiClient({required Dio dio, ApiErrorMapper? errorMapper})
|
||||||
|
: _dio = dio,
|
||||||
|
_errorMapper = errorMapper ?? const ApiErrorMapper();
|
||||||
|
|
||||||
final Dio _dio;
|
final Dio _dio;
|
||||||
|
final ApiErrorMapper _errorMapper;
|
||||||
|
|
||||||
/// 내부에서 사용하는 Dio 인스턴스
|
/// 내부에서 사용하는 Dio 인스턴스
|
||||||
/// 외부에서 Dio 직접 사용을 최소화하고, 가능하면 아래 헬퍼 메서드를 사용한다.
|
/// 외부에서 Dio 직접 사용을 최소화하고, 가능하면 아래 헬퍼 메서드를 사용한다.
|
||||||
Dio get dio => _dio;
|
Dio get dio => _dio;
|
||||||
|
|
||||||
ApiClient({required Dio dio}) : _dio = dio;
|
|
||||||
|
|
||||||
/// GET 요청 헬퍼
|
/// GET 요청 헬퍼
|
||||||
Future<Response<T>> get<T>(
|
Future<Response<T>> get<T>(
|
||||||
String path, {
|
String path, {
|
||||||
@@ -21,7 +26,14 @@ class ApiClient {
|
|||||||
Options? options,
|
Options? options,
|
||||||
CancelToken? cancelToken,
|
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 요청 헬퍼
|
/// POST 요청 헬퍼
|
||||||
@@ -32,7 +44,15 @@ class ApiClient {
|
|||||||
Options? options,
|
Options? options,
|
||||||
CancelToken? cancelToken,
|
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 요청 헬퍼
|
/// PATCH 요청 헬퍼
|
||||||
@@ -43,7 +63,15 @@ class ApiClient {
|
|||||||
Options? options,
|
Options? options,
|
||||||
CancelToken? cancelToken,
|
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 요청 헬퍼
|
/// DELETE 요청 헬퍼
|
||||||
@@ -54,7 +82,22 @@ class ApiClient {
|
|||||||
Options? options,
|
Options? options,
|
||||||
CancelToken? cancelToken,
|
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
|
// ignore_for_file: public_member_api_docs
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
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 헤더 주입
|
/// - 요청 전에 Authorization 헤더 주입
|
||||||
/// - 401 수신 시 토큰 갱신 및 원요청 1회 재시도 (구현 예정)
|
/// - 401 수신 시 토큰 갱신 및 원요청 1회 재시도
|
||||||
class AuthInterceptor extends Interceptor {
|
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
|
@override
|
||||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
|
Future<void> onRequest(
|
||||||
// TODO: 저장된 토큰을 읽어 Authorization 헤더에 주입한다.
|
RequestOptions options,
|
||||||
// final token = await _authRepository.getToken();
|
RequestInterceptorHandler handler,
|
||||||
// if (token != null && token.isNotEmpty) {
|
) async {
|
||||||
// options.headers['Authorization'] = 'Bearer $token';
|
final token = await _tokenStorage.readAccessToken();
|
||||||
// }
|
if (token != null && token.isNotEmpty) {
|
||||||
|
options.headers['Authorization'] = 'Bearer $token';
|
||||||
|
}
|
||||||
handler.next(options);
|
handler.next(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
Future<void> onError(
|
||||||
// TODO: 401 처리 로직(토큰 갱신 → 원요청 재시도) 구현
|
DioException err,
|
||||||
// if (err.response?.statusCode == 401) { ... }
|
ErrorInterceptorHandler handler,
|
||||||
handler.next(err);
|
) 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(
|
GoRoute(
|
||||||
path: '/inventory/inbound',
|
path: '/inventory/inbound',
|
||||||
name: 'inventory-inbound',
|
name: 'inventory-inbound',
|
||||||
builder: (context, state) => const InboundPage(),
|
builder: (context, state) => InboundPage(routeUri: state.uri),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/inventory/outbound',
|
path: '/inventory/outbound',
|
||||||
name: 'inventory-outbound',
|
name: 'inventory-outbound',
|
||||||
builder: (context, state) => const OutboundPage(),
|
builder: (context, state) => OutboundPage(routeUri: state.uri),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/inventory/rental',
|
path: '/inventory/rental',
|
||||||
name: 'inventory-rental',
|
name: 'inventory-rental',
|
||||||
builder: (context, state) => const RentalPage(),
|
builder: (context, state) => RentalPage(routeUri: state.uri),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/masters/vendors',
|
path: '/masters/vendors',
|
||||||
name: 'masters-vendors',
|
name: 'masters-vendors',
|
||||||
builder: (context, state) => const VendorPage(),
|
builder: (context, state) => VendorPage(routeUri: state.uri),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/masters/products',
|
path: '/masters/products',
|
||||||
name: 'masters-products',
|
name: 'masters-products',
|
||||||
builder: (context, state) => const ProductPage(),
|
builder: (context, state) => ProductPage(routeUri: state.uri),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/masters/warehouses',
|
path: '/masters/warehouses',
|
||||||
name: 'masters-warehouses',
|
name: 'masters-warehouses',
|
||||||
builder: (context, state) => const WarehousePage(),
|
builder: (context, state) => WarehousePage(routeUri: state.uri),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/masters/customers',
|
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 {
|
class ApprovalStepAssignmentInput {
|
||||||
ApprovalStepAssignmentInput({
|
ApprovalStepAssignmentInput({required this.approvalId, required this.steps});
|
||||||
required this.approvalId,
|
|
||||||
required this.steps,
|
|
||||||
});
|
|
||||||
|
|
||||||
final int approvalId;
|
final int approvalId;
|
||||||
final List<ApprovalStepAssignmentItem> steps;
|
final List<ApprovalStepAssignmentItem> steps;
|
||||||
|
|
||||||
Map<String, dynamic> toPayload() {
|
Map<String, dynamic> toPayload() {
|
||||||
return {
|
return {'id': approvalId, 'steps': steps.map((e) => e.toJson()).toList()};
|
||||||
'id': approvalId,
|
|
||||||
'steps': steps.map((e) => e.toJson()).toList(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:intl/intl.dart' as intl;
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
@@ -7,6 +8,8 @@ import '../../../../../core/config/environment.dart';
|
|||||||
import '../../../../../core/constants/app_sections.dart';
|
import '../../../../../core/constants/app_sections.dart';
|
||||||
import '../../../../../widgets/app_layout.dart';
|
import '../../../../../widgets/app_layout.dart';
|
||||||
import '../../../../../widgets/components/filter_bar.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 '../../../../../widgets/spec_page.dart';
|
||||||
import '../../domain/entities/approval_history_record.dart';
|
import '../../domain/entities/approval_history_record.dart';
|
||||||
import '../../domain/repositories/approval_history_repository.dart';
|
import '../../domain/repositories/approval_history_repository.dart';
|
||||||
@@ -145,6 +148,19 @@ class _ApprovalHistoryEnabledPageState
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
toolbar: FilterBar(
|
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: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 240,
|
width: 240,
|
||||||
@@ -180,21 +196,24 @@ class _ApprovalHistoryEnabledPageState
|
|||||||
),
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 220,
|
width: 220,
|
||||||
child: ShadButton.outline(
|
child: SuperportDateRangePickerButton(
|
||||||
onPressed: _pickDateRange,
|
value: _dateRange,
|
||||||
child: Row(
|
dateFormat: intl.DateFormat('yyyy-MM-dd'),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
enabled: !_controller.isLoading,
|
||||||
mainAxisSize: MainAxisSize.min,
|
firstDate: DateTime(DateTime.now().year - 5),
|
||||||
children: [
|
lastDate: DateTime(DateTime.now().year + 1),
|
||||||
const Icon(lucide.LucideIcons.calendar, size: 16),
|
initialDateRange:
|
||||||
const SizedBox(width: 8),
|
_dateRange ??
|
||||||
Text(
|
DateTimeRange(
|
||||||
_dateRange == null
|
start: DateTime.now().subtract(const Duration(days: 7)),
|
||||||
? '기간 선택'
|
end: DateTime.now(),
|
||||||
: '${_formatDate(_dateRange!.start)} ~ ${_formatDate(_dateRange!.end)}',
|
|
||||||
),
|
),
|
||||||
],
|
onChanged: (range) {
|
||||||
),
|
if (range == null) return;
|
||||||
|
setState(() => _dateRange = range);
|
||||||
|
_controller.updateDateRange(range.start, range.end);
|
||||||
|
_controller.fetch(page: 1);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_dateRange != null)
|
if (_dateRange != null)
|
||||||
@@ -202,17 +221,6 @@ class _ApprovalHistoryEnabledPageState
|
|||||||
onPressed: _controller.isLoading ? null : _clearDateRange,
|
onPressed: _controller.isLoading ? null : _clearDateRange,
|
||||||
child: const Text('기간 초기화'),
|
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(
|
child: ShadCard(
|
||||||
@@ -283,27 +291,6 @@ class _ApprovalHistoryEnabledPageState
|
|||||||
_controller.fetch(page: 1);
|
_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() {
|
void _clearDateRange() {
|
||||||
setState(() => _dateRange = null);
|
setState(() => _dateRange = null);
|
||||||
_controller.updateDateRange(null, null);
|
_controller.updateDateRange(null, null);
|
||||||
@@ -318,10 +305,6 @@ class _ApprovalHistoryEnabledPageState
|
|||||||
_controller.fetch(page: 1);
|
_controller.fetch(page: 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatDate(DateTime date) {
|
|
||||||
return DateFormat('yyyy-MM-dd').format(date.toLocal());
|
|
||||||
}
|
|
||||||
|
|
||||||
String _actionLabel(ApprovalHistoryActionFilter filter) {
|
String _actionLabel(ApprovalHistoryActionFilter filter) {
|
||||||
switch (filter) {
|
switch (filter) {
|
||||||
case ApprovalHistoryActionFilter.all:
|
case ApprovalHistoryActionFilter.all:
|
||||||
@@ -349,58 +332,60 @@ class _ApprovalHistoryTable extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final theme = ShadTheme.of(context);
|
||||||
final normalizedQuery = query.trim().toLowerCase();
|
final normalizedQuery = query.trim().toLowerCase();
|
||||||
final header = [
|
|
||||||
'ID',
|
final columns = const [
|
||||||
'결재번호',
|
Text('ID'),
|
||||||
'단계순서',
|
Text('결재번호'),
|
||||||
'승인자',
|
Text('단계순서'),
|
||||||
'행위',
|
Text('승인자'),
|
||||||
'변경전 상태',
|
Text('행위'),
|
||||||
'변경후 상태',
|
Text('변경전 상태'),
|
||||||
'작업일시',
|
Text('변경후 상태'),
|
||||||
'비고',
|
Text('작업일시'),
|
||||||
].map((label) => ShadTableCell.header(child: Text(label))).toList();
|
Text('비고'),
|
||||||
|
];
|
||||||
|
|
||||||
final rows = histories.map((history) {
|
final rows = histories.map((history) {
|
||||||
final isHighlighted =
|
final isHighlighted =
|
||||||
normalizedQuery.isNotEmpty &&
|
normalizedQuery.isNotEmpty &&
|
||||||
history.approvalNo.toLowerCase().contains(normalizedQuery);
|
history.approvalNo.toLowerCase().contains(normalizedQuery);
|
||||||
return [
|
final highlightStyle = theme.textTheme.small.copyWith(
|
||||||
ShadTableCell(child: Text(history.id.toString())),
|
fontWeight: FontWeight.w600,
|
||||||
ShadTableCell(
|
color: theme.colorScheme.foreground,
|
||||||
child: Text(
|
);
|
||||||
history.approvalNo,
|
final noteText = history.note?.trim();
|
||||||
style: isHighlighted
|
final noteContent = noteText?.isNotEmpty == true ? noteText : null;
|
||||||
? ShadTheme.of(
|
final subLabelStyle = theme.textTheme.muted.copyWith(
|
||||||
context,
|
fontSize: (theme.textTheme.muted.fontSize ?? 14) - 1,
|
||||||
).textTheme.small.copyWith(fontWeight: FontWeight.w600)
|
);
|
||||||
: null,
|
|
||||||
),
|
return <Widget>[
|
||||||
),
|
Text(history.id.toString()),
|
||||||
ShadTableCell(
|
Text(history.approvalNo, style: isHighlighted ? highlightStyle : null),
|
||||||
child: Text(
|
Text(history.stepOrder == null ? '-' : history.stepOrder.toString()),
|
||||||
history.stepOrder == null ? '-' : history.stepOrder.toString(),
|
Text(history.approver.name),
|
||||||
),
|
Column(
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
ShadTableCell(child: Text(history.approver.name)),
|
mainAxisSize: MainAxisSize.min,
|
||||||
ShadTableCell(child: Text(history.action.name)),
|
children: [
|
||||||
ShadTableCell(child: Text(history.fromStatus?.name ?? '-')),
|
Text(history.action.name),
|
||||||
ShadTableCell(child: Text(history.toStatus.name)),
|
if (noteContent != null) Text(noteContent, style: subLabelStyle),
|
||||||
ShadTableCell(
|
],
|
||||||
child: Text(dateFormat.format(history.actionAt.toLocal())),
|
|
||||||
),
|
|
||||||
ShadTableCell(
|
|
||||||
child: Text(
|
|
||||||
history.note?.trim().isEmpty ?? true ? '-' : history.note!,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
Text(history.fromStatus?.name ?? '-'),
|
||||||
|
Text(history.toStatus.name),
|
||||||
|
Text(dateFormat.format(history.actionAt.toLocal())),
|
||||||
|
Text(noteContent ?? '-'),
|
||||||
];
|
];
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
return ShadTable.list(
|
return SuperportTable(
|
||||||
header: header,
|
columns: columns,
|
||||||
children: rows,
|
rows: rows,
|
||||||
|
rowHeight: 64,
|
||||||
|
maxHeight: 520,
|
||||||
columnSpanExtent: (index) {
|
columnSpanExtent: (index) {
|
||||||
switch (index) {
|
switch (index) {
|
||||||
case 1:
|
case 1:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../../../../../core/config/environment.dart';
|
|||||||
import '../../../../../core/constants/app_sections.dart';
|
import '../../../../../core/constants/app_sections.dart';
|
||||||
import '../../../../../widgets/app_layout.dart';
|
import '../../../../../widgets/app_layout.dart';
|
||||||
import '../../../../../widgets/components/filter_bar.dart';
|
import '../../../../../widgets/components/filter_bar.dart';
|
||||||
|
import '../../../../../widgets/components/superport_dialog.dart';
|
||||||
import '../../../../../widgets/spec_page.dart';
|
import '../../../../../widgets/spec_page.dart';
|
||||||
import '../controllers/approval_step_controller.dart';
|
import '../controllers/approval_step_controller.dart';
|
||||||
import '../../domain/entities/approval_step_input.dart';
|
import '../../domain/entities/approval_step_input.dart';
|
||||||
@@ -528,73 +529,50 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.of(context, rootNavigator: true).pop();
|
Navigator.of(context, rootNavigator: true).pop();
|
||||||
if (detail == null) return;
|
if (detail == null) return;
|
||||||
await showDialog<void>(
|
final step = detail.step;
|
||||||
|
await SuperportDialog.show<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
dialog: SuperportDialog(
|
||||||
final step = detail.step;
|
title: '결재 단계 상세',
|
||||||
final theme = ShadTheme.of(dialogContext);
|
description: '결재번호 ${detail.approvalNo}',
|
||||||
return Dialog(
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
insetPadding: const EdgeInsets.all(24),
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
clipBehavior: Clip.antiAlias,
|
horizontal: 20,
|
||||||
child: ShadCard(
|
vertical: 18,
|
||||||
title: Text('결재 단계 상세', style: theme.textTheme.h3),
|
),
|
||||||
description: Text(
|
child: Column(
|
||||||
'결재번호 ${detail.approvalNo}',
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: theme.textTheme.muted,
|
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: step.decidedAt == null
|
||||||
|
? '-'
|
||||||
|
: _formatDate(step.decidedAt!),
|
||||||
),
|
),
|
||||||
footer: Row(
|
_DetailRow(label: '템플릿', value: detail.templateName ?? '-'),
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
_DetailRow(label: '트랜잭션번호', value: detail.transactionNo ?? '-'),
|
||||||
children: [
|
const SizedBox(height: 12),
|
||||||
ShadButton.ghost(
|
Text(
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
'비고',
|
||||||
child: const Text('닫기'),
|
style: ShadTheme.of(
|
||||||
),
|
context,
|
||||||
],
|
).textTheme.small.copyWith(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
child: Padding(
|
const SizedBox(height: 8),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
ShadTextarea(
|
||||||
child: Column(
|
initialValue: step.note ?? '',
|
||||||
mainAxisSize: MainAxisSize.min,
|
readOnly: true,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
minHeight: 80,
|
||||||
children: [
|
maxHeight: 200,
|
||||||
_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: step.decidedAt == null
|
|
||||||
? '-'
|
|
||||||
: _formatDate(step.decidedAt!),
|
|
||||||
),
|
|
||||||
_DetailRow(label: '템플릿', value: detail.templateName ?? '-'),
|
|
||||||
_DetailRow(
|
|
||||||
label: '트랜잭션번호',
|
|
||||||
value: detail.transactionNo ?? '-',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
'비고',
|
|
||||||
style: theme.textTheme.small.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
ShadTextarea(
|
|
||||||
initialValue: step.note ?? '',
|
|
||||||
readOnly: true,
|
|
||||||
minHeight: 80,
|
|
||||||
maxHeight: 200,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -724,102 +702,93 @@ class _StepFormDialogState extends State<_StepFormDialog> {
|
|||||||
final theme = ShadTheme.of(context);
|
final theme = ShadTheme.of(context);
|
||||||
final materialTheme = Theme.of(context);
|
final materialTheme = Theme.of(context);
|
||||||
|
|
||||||
return Dialog(
|
return SuperportDialog(
|
||||||
insetPadding: const EdgeInsets.all(24),
|
title: widget.title,
|
||||||
clipBehavior: Clip.antiAlias,
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
child: ShadCard(
|
primaryAction: ShadButton(
|
||||||
title: Text(widget.title, style: theme.textTheme.h3),
|
key: const ValueKey('step_form_submit'),
|
||||||
footer: Row(
|
onPressed: _handleSubmit,
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
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(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
ShadButton.ghost(
|
if (!widget.isEditing)
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
child: const Text('취소'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
ShadButton(
|
|
||||||
key: const ValueKey('step_form_submit'),
|
|
||||||
onPressed: _handleSubmit,
|
|
||||||
child: Text(widget.submitLabel),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (!widget.isEditing)
|
|
||||||
_FormFieldBlock(
|
|
||||||
label: '결재 ID',
|
|
||||||
errorText: _errors['approvalId'],
|
|
||||||
child: ShadInput(
|
|
||||||
key: const ValueKey('step_form_approval_id'),
|
|
||||||
controller: _approvalIdController,
|
|
||||||
onChanged: (_) => _clearError('approvalId'),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else ...[
|
|
||||||
_FormFieldBlock(
|
|
||||||
label: '결재 ID',
|
|
||||||
child: ShadInput(
|
|
||||||
controller: _approvalIdController,
|
|
||||||
readOnly: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_FormFieldBlock(
|
|
||||||
label: '결재번호',
|
|
||||||
child: ShadInput(
|
|
||||||
controller: _approvalNoController,
|
|
||||||
readOnly: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if (!widget.isEditing) const SizedBox(height: 16),
|
|
||||||
_FormFieldBlock(
|
_FormFieldBlock(
|
||||||
label: '단계 순서',
|
label: '결재 ID',
|
||||||
errorText: _errors['stepOrder'],
|
errorText: _errors['approvalId'],
|
||||||
child: ShadInput(
|
child: ShadInput(
|
||||||
key: const ValueKey('step_form_step_order'),
|
key: const ValueKey('step_form_approval_id'),
|
||||||
controller: _stepOrderController,
|
controller: _approvalIdController,
|
||||||
onChanged: (_) => _clearError('stepOrder'),
|
onChanged: (_) => _clearError('approvalId'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else ...[
|
||||||
|
_FormFieldBlock(
|
||||||
|
label: '결재 ID',
|
||||||
|
child: ShadInput(
|
||||||
|
controller: _approvalIdController,
|
||||||
|
readOnly: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_FormFieldBlock(
|
_FormFieldBlock(
|
||||||
label: '승인자 ID',
|
label: '결재번호',
|
||||||
errorText: _errors['approverId'],
|
|
||||||
child: ShadInput(
|
child: ShadInput(
|
||||||
key: const ValueKey('step_form_approver_id'),
|
controller: _approvalNoController,
|
||||||
controller: _approverIdController,
|
readOnly: true,
|
||||||
onChanged: (_) => _clearError('approverId'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
|
||||||
_FormFieldBlock(
|
|
||||||
label: '비고',
|
|
||||||
helperText: '필요 시 단계에 대한 참고 내용을 남길 수 있습니다.',
|
|
||||||
child: ShadTextarea(
|
|
||||||
key: const ValueKey('step_form_note'),
|
|
||||||
controller: _noteController,
|
|
||||||
minHeight: 100,
|
|
||||||
maxHeight: 200,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (_errors['form'] != null)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 12),
|
|
||||||
child: Text(
|
|
||||||
_errors['form']!,
|
|
||||||
style: theme.textTheme.small.copyWith(
|
|
||||||
color: materialTheme.colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
if (!widget.isEditing) const SizedBox(height: 16),
|
||||||
|
_FormFieldBlock(
|
||||||
|
label: '단계 순서',
|
||||||
|
errorText: _errors['stepOrder'],
|
||||||
|
child: ShadInput(
|
||||||
|
key: const ValueKey('step_form_step_order'),
|
||||||
|
controller: _stepOrderController,
|
||||||
|
onChanged: (_) => _clearError('stepOrder'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_FormFieldBlock(
|
||||||
|
label: '승인자 ID',
|
||||||
|
errorText: _errors['approverId'],
|
||||||
|
child: ShadInput(
|
||||||
|
key: const ValueKey('step_form_approver_id'),
|
||||||
|
controller: _approverIdController,
|
||||||
|
onChanged: (_) => _clearError('approverId'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_FormFieldBlock(
|
||||||
|
label: '비고',
|
||||||
|
helperText: '필요 시 단계에 대한 참고 내용을 남길 수 있습니다.',
|
||||||
|
child: ShadTextarea(
|
||||||
|
key: const ValueKey('step_form_note'),
|
||||||
|
controller: _noteController,
|
||||||
|
minHeight: 100,
|
||||||
|
maxHeight: 200,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_errors['form'] != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12),
|
||||||
|
child: Text(
|
||||||
|
_errors['form']!,
|
||||||
|
style: theme.textTheme.small.copyWith(
|
||||||
|
color: materialTheme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../../../../../core/config/environment.dart';
|
|||||||
import '../../../../../core/constants/app_sections.dart';
|
import '../../../../../core/constants/app_sections.dart';
|
||||||
import '../../../../../widgets/app_layout.dart';
|
import '../../../../../widgets/app_layout.dart';
|
||||||
import '../../../../../widgets/components/filter_bar.dart';
|
import '../../../../../widgets/components/filter_bar.dart';
|
||||||
|
import '../../../../../widgets/components/superport_table.dart';
|
||||||
import '../../../../../widgets/components/superport_dialog.dart';
|
import '../../../../../widgets/components/superport_dialog.dart';
|
||||||
import '../../../../../widgets/spec_page.dart';
|
import '../../../../../widgets/spec_page.dart';
|
||||||
import '../../../domain/entities/approval_template.dart';
|
import '../../../domain/entities/approval_template.dart';
|
||||||
@@ -151,6 +152,18 @@ class _ApprovalTemplateEnabledPageState
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
toolbar: FilterBar(
|
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: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 260,
|
width: 260,
|
||||||
@@ -183,16 +196,6 @@ class _ApprovalTemplateEnabledPageState
|
|||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ShadButton.outline(
|
|
||||||
onPressed: _controller.isLoading ? null : _applyFilters,
|
|
||||||
child: const Text('검색 적용'),
|
|
||||||
),
|
|
||||||
ShadButton.ghost(
|
|
||||||
onPressed: !_controller.isLoading && showReset
|
|
||||||
? _resetFilters
|
|
||||||
: null,
|
|
||||||
child: const Text('필터 초기화'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: ShadCard(
|
child: ShadCard(
|
||||||
@@ -213,97 +216,95 @@ class _ApprovalTemplateEnabledPageState
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SuperportTable.fromCells(
|
||||||
height: 480,
|
header: const [
|
||||||
child: ShadTable.list(
|
ShadTableCell.header(child: Text('ID')),
|
||||||
header:
|
ShadTableCell.header(child: Text('템플릿코드')),
|
||||||
['ID', '템플릿코드', '템플릿명', '설명', '사용', '변경일시', '동작']
|
ShadTableCell.header(child: Text('템플릿명')),
|
||||||
.map(
|
ShadTableCell.header(child: Text('설명')),
|
||||||
(e) => ShadTableCell.header(child: Text(e)),
|
ShadTableCell.header(child: Text('사용')),
|
||||||
)
|
ShadTableCell.header(child: Text('변경일시')),
|
||||||
.toList(),
|
ShadTableCell.header(child: Text('동작')),
|
||||||
columnSpanExtent: (index) {
|
],
|
||||||
switch (index) {
|
rows: templates.map((template) {
|
||||||
case 2:
|
return [
|
||||||
return const FixedTableSpanExtent(220);
|
ShadTableCell(child: Text('${template.id}')),
|
||||||
case 3:
|
ShadTableCell(child: Text(template.code)),
|
||||||
return const FixedTableSpanExtent(260);
|
ShadTableCell(child: Text(template.name)),
|
||||||
case 4:
|
ShadTableCell(
|
||||||
return const FixedTableSpanExtent(100);
|
child: Text(
|
||||||
case 5:
|
template.description?.isNotEmpty == true
|
||||||
return const FixedTableSpanExtent(180);
|
? template.description!
|
||||||
case 6:
|
: '-',
|
||||||
return const FixedTableSpanExtent(160);
|
|
||||||
default:
|
|
||||||
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
|
ShadTableCell(
|
||||||
? const ShadBadge(child: Text('사용'))
|
child: template.isActive
|
||||||
: const ShadBadge.outline(
|
? const ShadBadge(child: Text('사용'))
|
||||||
child: Text('미사용'),
|
: const ShadBadge.outline(child: Text('미사용')),
|
||||||
|
),
|
||||||
|
ShadTableCell(
|
||||||
|
child: Text(
|
||||||
|
template.updatedAt == null
|
||||||
|
? '-'
|
||||||
|
: _dateFormat.format(
|
||||||
|
template.updatedAt!.toLocal(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ShadTableCell(
|
),
|
||||||
child: Text(
|
ShadTableCell(
|
||||||
template.updatedAt == null
|
alignment: Alignment.centerRight,
|
||||||
? '-'
|
child: Wrap(
|
||||||
: _dateFormat.format(
|
spacing: 8,
|
||||||
template.updatedAt!.toLocal(),
|
children: [
|
||||||
),
|
ShadButton.ghost(
|
||||||
),
|
key: ValueKey(
|
||||||
),
|
'template_edit_${template.id}',
|
||||||
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),
|
size: ShadButtonSize.sm,
|
||||||
template.isActive
|
onPressed: _controller.isSubmitting
|
||||||
? ShadButton.outline(
|
? null
|
||||||
size: ShadButtonSize.sm,
|
: () => _openEditTemplate(template),
|
||||||
onPressed: _controller.isSubmitting
|
child: const Text('수정'),
|
||||||
? null
|
),
|
||||||
: () =>
|
template.isActive
|
||||||
_confirmDelete(template),
|
? ShadButton.outline(
|
||||||
child: const Text('삭제'),
|
size: ShadButtonSize.sm,
|
||||||
)
|
onPressed: _controller.isSubmitting
|
||||||
: ShadButton.outline(
|
? null
|
||||||
size: ShadButtonSize.sm,
|
: () => _confirmDelete(template),
|
||||||
onPressed: _controller.isSubmitting
|
child: const Text('삭제'),
|
||||||
? null
|
)
|
||||||
: () =>
|
: ShadButton.outline(
|
||||||
_confirmRestore(template),
|
size: ShadButtonSize.sm,
|
||||||
child: const Text('복구'),
|
onPressed: _controller.isSubmitting
|
||||||
),
|
? null
|
||||||
],
|
: () => _confirmRestore(template),
|
||||||
),
|
child: const Text('복구'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
];
|
),
|
||||||
}).toList(),
|
];
|
||||||
),
|
}).toList(),
|
||||||
|
rowHeight: 56,
|
||||||
|
maxHeight: 480,
|
||||||
|
columnSpanExtent: (index) {
|
||||||
|
switch (index) {
|
||||||
|
case 2:
|
||||||
|
return const FixedTableSpanExtent(220);
|
||||||
|
case 3:
|
||||||
|
return const FixedTableSpanExtent(260);
|
||||||
|
case 4:
|
||||||
|
return const FixedTableSpanExtent(100);
|
||||||
|
case 5:
|
||||||
|
return const FixedTableSpanExtent(180);
|
||||||
|
case 6:
|
||||||
|
return const FixedTableSpanExtent(160);
|
||||||
|
default:
|
||||||
|
return const FixedTableSpanExtent(140);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
@@ -382,26 +383,23 @@ class _ApprovalTemplateEnabledPageState
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _confirmDelete(ApprovalTemplate template) async {
|
Future<void> _confirmDelete(ApprovalTemplate template) async {
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await SuperportDialog.show<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
dialog: SuperportDialog(
|
||||||
return AlertDialog(
|
title: '템플릿 삭제',
|
||||||
title: const Text('템플릿 삭제'),
|
description:
|
||||||
content: Text(
|
|
||||||
'"${template.name}" 템플릿을 삭제하시겠습니까?\n삭제 시 템플릿은 미사용 상태로 전환됩니다.',
|
'"${template.name}" 템플릿을 삭제하시겠습니까?\n삭제 시 템플릿은 미사용 상태로 전환됩니다.',
|
||||||
|
actions: [
|
||||||
|
ShadButton.ghost(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('취소'),
|
||||||
),
|
),
|
||||||
actions: [
|
ShadButton(
|
||||||
TextButton(
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
child: const Text('삭제'),
|
||||||
child: const Text('취소'),
|
),
|
||||||
),
|
],
|
||||||
FilledButton.tonal(
|
),
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
|
||||||
child: const Text('삭제'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
if (confirmed != true) return;
|
if (confirmed != true) return;
|
||||||
final ok = await _controller.delete(template.id);
|
final ok = await _controller.delete(template.id);
|
||||||
@@ -412,24 +410,22 @@ class _ApprovalTemplateEnabledPageState
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _confirmRestore(ApprovalTemplate template) async {
|
Future<void> _confirmRestore(ApprovalTemplate template) async {
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await SuperportDialog.show<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
dialog: SuperportDialog(
|
||||||
return AlertDialog(
|
title: '템플릿 복구',
|
||||||
title: const Text('템플릿 복구'),
|
description: '"${template.name}" 템플릿을 복구하시겠습니까?',
|
||||||
content: Text('"${template.name}" 템플릿을 복구하시겠습니까?'),
|
actions: [
|
||||||
actions: [
|
ShadButton.ghost(
|
||||||
TextButton(
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
child: const Text('취소'),
|
||||||
child: const Text('취소'),
|
),
|
||||||
),
|
ShadButton(
|
||||||
FilledButton(
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
child: const Text('복구'),
|
||||||
child: const Text('복구'),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
if (confirmed != true) return;
|
if (confirmed != true) return;
|
||||||
final restored = await _controller.restore(template.id);
|
final restored = await _controller.restore(template.id);
|
||||||
@@ -454,10 +450,74 @@ class _ApprovalTemplateEnabledPageState
|
|||||||
String? errorText;
|
String? errorText;
|
||||||
StateSetter? modalSetState;
|
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>(
|
final result = await showSuperportDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
title: isEdit ? '템플릿 수정' : '템플릿 생성',
|
title: isEdit ? '템플릿 수정' : '템플릿 생성',
|
||||||
barrierDismissible: !isSaving,
|
barrierDismissible: !isSaving,
|
||||||
|
onSubmit: handleSubmit,
|
||||||
body: StatefulBuilder(
|
body: StatefulBuilder(
|
||||||
builder: (dialogContext, setModalState) {
|
builder: (dialogContext, setModalState) {
|
||||||
modalSetState = setModalState;
|
modalSetState = setModalState;
|
||||||
@@ -594,68 +654,7 @@ class _ApprovalTemplateEnabledPageState
|
|||||||
child: const Text('취소'),
|
child: const Text('취소'),
|
||||||
),
|
),
|
||||||
ShadButton(
|
ShadButton(
|
||||||
onPressed: () async {
|
onPressed: handleSubmit,
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Text(isEdit ? '수정 완료' : '생성 완료'),
|
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 {
|
class DashboardPage extends StatelessWidget {
|
||||||
const DashboardPage({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const SpecPage(
|
return AppLayout(
|
||||||
title: '대시보드',
|
title: '대시보드',
|
||||||
summary: '오늘 입고/출고, 결재 대기, 최근 트랜잭션을 한 눈에 볼 수 있는 메인 화면 구성.',
|
subtitle: '입·출·대여 현황과 결재 대기를 한 눈에 확인합니다.',
|
||||||
sections: [
|
breadcrumbs: const [AppBreadcrumbItem(label: '대시보드')],
|
||||||
SpecSection(
|
child: SingleChildScrollView(
|
||||||
title: '주요 위젯',
|
padding: const EdgeInsets.only(right: 12, bottom: 24),
|
||||||
items: [
|
child: Column(
|
||||||
'오늘 입고/출고 건수, 대기 결재 수 KPI 카드',
|
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 idController = TextEditingController();
|
||||||
final passwordController = TextEditingController();
|
final passwordController = TextEditingController();
|
||||||
bool rememberMe = false;
|
bool rememberMe = false;
|
||||||
|
bool isLoading = false;
|
||||||
|
String? errorMessage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -23,7 +25,35 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
super.dispose();
|
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);
|
context.go(dashboardRoutePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,9 +103,33 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
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(
|
ShadButton(
|
||||||
onPressed: _handleSubmit,
|
onPressed: isLoading ? null : _handleSubmit,
|
||||||
child: const Text('로그인'),
|
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/core/constants/app_sections.dart';
|
||||||
import 'package:superport_v2/widgets/app_layout.dart';
|
import 'package:superport_v2/widgets/app_layout.dart';
|
||||||
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
||||||
|
import 'package:superport_v2/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/models/postal_search_result.dart';
|
||||||
import 'package:superport_v2/features/util/postal_search/presentation/widgets/postal_search_dialog.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(
|
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: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 260,
|
width: 260,
|
||||||
@@ -251,31 +279,6 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
|||||||
.toList(),
|
.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(
|
child: ShadCard(
|
||||||
@@ -515,395 +518,427 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
|||||||
final codeError = ValueNotifier<String?>(null);
|
final codeError = ValueNotifier<String?>(null);
|
||||||
final nameError = ValueNotifier<String?>(null);
|
final nameError = ValueNotifier<String?>(null);
|
||||||
final typeError = ValueNotifier<String?>(null);
|
final typeError = ValueNotifier<String?>(null);
|
||||||
|
final zipcodeError = ValueNotifier<String?>(null);
|
||||||
|
|
||||||
await showDialog<bool>(
|
var isApplyingPostalSelection = false;
|
||||||
context: parentContext,
|
|
||||||
builder: (dialogContext) {
|
void handleZipcodeChange() {
|
||||||
final theme = ShadTheme.of(dialogContext);
|
if (isApplyingPostalSelection) {
|
||||||
final materialTheme = Theme.of(dialogContext);
|
return;
|
||||||
final navigator = Navigator.of(dialogContext);
|
}
|
||||||
Future<void> openPostalSearch() async {
|
final text = zipcodeController.text.trim();
|
||||||
final keyword = zipcodeController.text.trim();
|
final selection = selectedPostalNotifier.value;
|
||||||
final result = await showPostalSearchDialog(
|
if (text.isEmpty) {
|
||||||
dialogContext,
|
if (selection != null) {
|
||||||
initialKeyword: keyword.isEmpty ? null : keyword,
|
selectedPostalNotifier.value = null;
|
||||||
);
|
|
||||||
if (result == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
zipcodeController
|
|
||||||
..text = result.zipcode
|
|
||||||
..selection = TextSelection.collapsed(
|
|
||||||
offset: result.zipcode.length,
|
|
||||||
);
|
|
||||||
selectedPostalNotifier.value = result;
|
|
||||||
if (result.fullAddress.isNotEmpty) {
|
|
||||||
addressController
|
|
||||||
..text = result.fullAddress
|
|
||||||
..selection = TextSelection.collapsed(
|
|
||||||
offset: addressController.text.length,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
zipcodeError.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selection != null && selection.zipcode != text) {
|
||||||
|
selectedPostalNotifier.value = null;
|
||||||
|
}
|
||||||
|
if (zipcodeError.value != null) {
|
||||||
|
zipcodeError.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Dialog(
|
void handlePostalSelectionChange() {
|
||||||
insetPadding: const EdgeInsets.all(24),
|
if (selectedPostalNotifier.value != null) {
|
||||||
clipBehavior: Clip.antiAlias,
|
zipcodeError.value = null;
|
||||||
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>(
|
|
||||||
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 email = emailController.text.trim();
|
|
||||||
final mobile = mobileController.text.trim();
|
|
||||||
final zipcode = zipcodeController.text.trim();
|
|
||||||
final address = addressController.text.trim();
|
|
||||||
final note = noteController.text.trim();
|
|
||||||
final partner = partnerNotifier.value;
|
|
||||||
var general = generalNotifier.value;
|
|
||||||
|
|
||||||
codeError.value = code.isEmpty
|
zipcodeController.addListener(handleZipcodeChange);
|
||||||
? '고객사코드를 입력하세요.'
|
selectedPostalNotifier.addListener(handlePostalSelectionChange);
|
||||||
: null;
|
|
||||||
nameError.value = name.isEmpty
|
|
||||||
? '고객사명을 입력하세요.'
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!partner && !general) {
|
Future<void> openPostalSearch(BuildContext dialogContext) async {
|
||||||
general = true;
|
final keyword = zipcodeController.text.trim();
|
||||||
generalNotifier.value = true;
|
final result = await showPostalSearchDialog(
|
||||||
}
|
dialogContext,
|
||||||
|
initialKeyword: keyword.isEmpty ? null : keyword,
|
||||||
|
);
|
||||||
|
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
|
||||||
|
..text = result.fullAddress
|
||||||
|
..selection = TextSelection.collapsed(
|
||||||
|
offset: addressController.text.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
typeError.value = (!partner && !general)
|
await SuperportDialog.show<bool>(
|
||||||
? '파트너/일반 중 하나 이상 선택하세요.'
|
context: parentContext,
|
||||||
: null;
|
dialog: SuperportDialog(
|
||||||
|
title: isEdit ? '고객사 수정' : '고객사 등록',
|
||||||
|
description: '고객사 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.',
|
||||||
|
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 email = emailController.text.trim();
|
||||||
|
final mobile = mobileController.text.trim();
|
||||||
|
final zipcode = zipcodeController.text.trim();
|
||||||
|
final address = addressController.text.trim();
|
||||||
|
final note = noteController.text.trim();
|
||||||
|
final partner = partnerNotifier.value;
|
||||||
|
var general = generalNotifier.value;
|
||||||
|
final selectedPostal = selectedPostalNotifier.value;
|
||||||
|
|
||||||
if (codeError.value != null ||
|
codeError.value = code.isEmpty ? '고객사코드를 입력하세요.' : null;
|
||||||
nameError.value != null ||
|
nameError.value = name.isEmpty ? '고객사명을 입력하세요.' : null;
|
||||||
typeError.value != null) {
|
zipcodeError.value =
|
||||||
return;
|
zipcode.isNotEmpty && selectedPostal == null
|
||||||
}
|
? '우편번호 검색으로 주소를 선택하세요.'
|
||||||
|
: null;
|
||||||
|
|
||||||
saving.value = true;
|
if (!partner && !general) {
|
||||||
final input = CustomerInput(
|
general = true;
|
||||||
customerCode: code,
|
generalNotifier.value = true;
|
||||||
customerName: name,
|
}
|
||||||
isPartner: partner,
|
|
||||||
isGeneral: general,
|
typeError.value = (!partner && !general)
|
||||||
email: email.isEmpty ? null : email,
|
? '파트너/일반 중 하나 이상 선택하세요.'
|
||||||
mobileNo: mobile.isEmpty ? null : mobile,
|
: null;
|
||||||
zipcode: zipcode.isEmpty ? null : zipcode,
|
|
||||||
addressDetail: address.isEmpty
|
if (codeError.value != null ||
|
||||||
? null
|
nameError.value != null ||
|
||||||
: address,
|
zipcodeError.value != null ||
|
||||||
isActive: isActiveNotifier.value,
|
typeError.value != null) {
|
||||||
note: note.isEmpty ? null : note,
|
return;
|
||||||
);
|
}
|
||||||
final response = isEdit
|
|
||||||
? await _controller.update(
|
saving.value = true;
|
||||||
customerId!,
|
final input = CustomerInput(
|
||||||
input,
|
customerCode: code,
|
||||||
)
|
customerName: name,
|
||||||
: await _controller.create(input);
|
isPartner: partner,
|
||||||
saving.value = false;
|
isGeneral: general,
|
||||||
if (response != null) {
|
email: email.isEmpty ? null : email,
|
||||||
if (!navigator.mounted) {
|
mobileNo: mobile.isEmpty ? null : mobile,
|
||||||
return;
|
zipcode: zipcode.isEmpty ? null : zipcode,
|
||||||
}
|
addressDetail: address.isEmpty ? null : address,
|
||||||
if (mounted) {
|
isActive: isActiveNotifier.value,
|
||||||
_showSnack(
|
note: note.isEmpty ? null : note,
|
||||||
isEdit ? '고객사를 수정했습니다.' : '고객사를 등록했습니다.',
|
);
|
||||||
);
|
final navigator = Navigator.of(context);
|
||||||
}
|
final response = isEdit
|
||||||
navigator.pop(true);
|
? await _controller.update(customerId!, 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 ? '저장' : '등록'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
children: [
|
||||||
|
ValueListenableBuilder<String?>(
|
||||||
|
valueListenable: codeError,
|
||||||
|
builder: (_, errorText, __) {
|
||||||
|
return _FormField(
|
||||||
|
label: '고객사코드',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ShadInput(
|
||||||
|
controller: codeController,
|
||||||
|
readOnly: isEdit,
|
||||||
|
onChanged: (_) {
|
||||||
|
if (codeController.text.trim().isNotEmpty) {
|
||||||
|
codeError.value = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Text(isEdit ? '저장' : '등록'),
|
),
|
||||||
),
|
if (errorText != null)
|
||||||
],
|
Padding(
|
||||||
);
|
padding: const EdgeInsets.only(top: 6),
|
||||||
},
|
child: Text(
|
||||||
),
|
errorText,
|
||||||
child: SingleChildScrollView(
|
style: theme.textTheme.small.copyWith(
|
||||||
padding: const EdgeInsets.only(right: 12),
|
color: materialTheme.colorScheme.error,
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
ValueListenableBuilder<String?>(
|
|
||||||
valueListenable: codeError,
|
|
||||||
builder: (_, errorText, __) {
|
|
||||||
return _FormField(
|
|
||||||
label: '고객사코드',
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
ShadInput(
|
|
||||||
controller: codeController,
|
|
||||||
readOnly: isEdit,
|
|
||||||
onChanged: (_) {
|
|
||||||
if (codeController.text.trim().isNotEmpty) {
|
|
||||||
codeError.value = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (errorText != null)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 6),
|
|
||||||
child: Text(
|
|
||||||
errorText,
|
|
||||||
style: theme.textTheme.small.copyWith(
|
|
||||||
color: materialTheme.colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ValueListenableBuilder<String?>(
|
|
||||||
valueListenable: nameError,
|
|
||||||
builder: (_, errorText, __) {
|
|
||||||
return _FormField(
|
|
||||||
label: '고객사명',
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
ShadInput(
|
|
||||||
controller: nameController,
|
|
||||||
onChanged: (_) {
|
|
||||||
if (nameController.text.trim().isNotEmpty) {
|
|
||||||
nameError.value = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
if (errorText != null)
|
],
|
||||||
Padding(
|
),
|
||||||
padding: const EdgeInsets.only(top: 6),
|
);
|
||||||
child: Text(
|
},
|
||||||
errorText,
|
),
|
||||||
style: theme.textTheme.small.copyWith(
|
const SizedBox(height: 16),
|
||||||
color: materialTheme.colorScheme.error,
|
ValueListenableBuilder<String?>(
|
||||||
),
|
valueListenable: nameError,
|
||||||
|
builder: (_, errorText, __) {
|
||||||
|
return _FormField(
|
||||||
|
label: '고객사명',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ShadInput(
|
||||||
|
controller: nameController,
|
||||||
|
onChanged: (_) {
|
||||||
|
if (nameController.text.trim().isNotEmpty) {
|
||||||
|
nameError.value = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (errorText != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Text(
|
||||||
|
errorText,
|
||||||
|
style: theme.textTheme.small.copyWith(
|
||||||
|
color: materialTheme.colorScheme.error,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
);
|
),
|
||||||
},
|
);
|
||||||
),
|
},
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
ValueListenableBuilder<bool>(
|
const SizedBox(height: 16),
|
||||||
valueListenable: partnerNotifier,
|
ValueListenableBuilder<bool>(
|
||||||
builder: (_, partner, __) {
|
valueListenable: partnerNotifier,
|
||||||
return ValueListenableBuilder<bool>(
|
builder: (_, partner, __) {
|
||||||
valueListenable: generalNotifier,
|
return ValueListenableBuilder<bool>(
|
||||||
builder: (_, general, __) {
|
valueListenable: generalNotifier,
|
||||||
return ValueListenableBuilder<String?>(
|
builder: (_, general, __) {
|
||||||
valueListenable: typeError,
|
return ValueListenableBuilder<String?>(
|
||||||
builder: (_, errorText, __) {
|
valueListenable: typeError,
|
||||||
final onChanged = saving.value
|
builder: (_, errorText, __) {
|
||||||
? null
|
final onChanged = isSaving
|
||||||
: (bool? value) {
|
? null
|
||||||
if (value == null) return;
|
: (bool? value) {
|
||||||
partnerNotifier.value = value;
|
if (value == null) return;
|
||||||
if (!value && !generalNotifier.value) {
|
partnerNotifier.value = value;
|
||||||
typeError.value =
|
if (!value && !generalNotifier.value) {
|
||||||
'파트너/일반 중 하나 이상 선택하세요.';
|
typeError.value =
|
||||||
} else {
|
'파트너/일반 중 하나 이상 선택하세요.';
|
||||||
typeError.value = null;
|
} else {
|
||||||
}
|
typeError.value = null;
|
||||||
};
|
}
|
||||||
final onChangedGeneral = saving.value
|
};
|
||||||
? null
|
final onChangedGeneral = isSaving
|
||||||
: (bool? value) {
|
? null
|
||||||
if (value == null) return;
|
: (bool? value) {
|
||||||
generalNotifier.value = value;
|
if (value == null) return;
|
||||||
if (!value && !partnerNotifier.value) {
|
generalNotifier.value = value;
|
||||||
typeError.value =
|
if (!value && !partnerNotifier.value) {
|
||||||
'파트너/일반 중 하나 이상 선택하세요.';
|
typeError.value =
|
||||||
} else {
|
'파트너/일반 중 하나 이상 선택하세요.';
|
||||||
typeError.value = null;
|
} else {
|
||||||
}
|
typeError.value = null;
|
||||||
};
|
}
|
||||||
return _FormField(
|
};
|
||||||
label: '유형',
|
return _FormField(
|
||||||
child: Column(
|
label: '유형',
|
||||||
crossAxisAlignment:
|
child: Column(
|
||||||
CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
ShadCheckbox(
|
ShadCheckbox(
|
||||||
value: partner,
|
value: partner,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Text('파트너'),
|
const Text('파트너'),
|
||||||
const SizedBox(width: 24),
|
const SizedBox(width: 24),
|
||||||
ShadCheckbox(
|
ShadCheckbox(
|
||||||
value: general,
|
value: general,
|
||||||
onChanged: onChangedGeneral,
|
onChanged: onChangedGeneral,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Text('일반'),
|
const Text('일반'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (errorText != null)
|
if (errorText != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(top: 6),
|
||||||
top: 6,
|
child: Text(
|
||||||
),
|
errorText,
|
||||||
child: Text(
|
style: theme.textTheme.small.copyWith(
|
||||||
errorText,
|
color:
|
||||||
style: theme.textTheme.small
|
materialTheme.colorScheme.error,
|
||||||
.copyWith(
|
|
||||||
color: materialTheme
|
|
||||||
.colorScheme
|
|
||||||
.error,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_FormField(
|
|
||||||
label: '이메일',
|
|
||||||
child: ShadInput(
|
|
||||||
controller: emailController,
|
|
||||||
keyboardType: TextInputType.emailAddress,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_FormField(
|
|
||||||
label: '연락처',
|
|
||||||
child: ShadInput(
|
|
||||||
controller: mobileController,
|
|
||||||
keyboardType: TextInputType.phone,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_FormField(
|
|
||||||
label: '우편번호',
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: ShadInput(
|
|
||||||
controller: zipcodeController,
|
|
||||||
placeholder: const Text('예: 06000'),
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
ShadButton.outline(
|
|
||||||
onPressed: saving.value
|
|
||||||
? null
|
|
||||||
: openPostalSearch,
|
|
||||||
child: const Text('검색'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
ValueListenableBuilder<PostalSearchResult?>(
|
|
||||||
valueListenable: selectedPostalNotifier,
|
|
||||||
builder: (_, selection, __) {
|
|
||||||
if (selection == null) {
|
|
||||||
return Text(
|
|
||||||
'검색 버튼을 눌러 주소를 선택하세요.',
|
|
||||||
style: theme.textTheme.small.copyWith(
|
|
||||||
color: theme.colorScheme.mutedForeground,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final fullAddress = selection.fullAddress;
|
|
||||||
return Text(
|
|
||||||
fullAddress.isEmpty
|
|
||||||
? '선택한 우편번호에 주소 정보가 없습니다.'
|
|
||||||
: fullAddress,
|
|
||||||
style: theme.textTheme.small,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
);
|
||||||
],
|
},
|
||||||
),
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_FormField(
|
||||||
|
label: '이메일',
|
||||||
|
child: ShadInput(
|
||||||
|
controller: emailController,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
_FormField(
|
const SizedBox(height: 16),
|
||||||
label: '상세주소',
|
_FormField(
|
||||||
child: ShadInput(
|
label: '연락처',
|
||||||
controller: addressController,
|
child: ShadInput(
|
||||||
placeholder: const Text('상세주소 입력'),
|
controller: mobileController,
|
||||||
),
|
keyboardType: TextInputType.phone,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
ValueListenableBuilder<bool>(
|
const SizedBox(height: 16),
|
||||||
valueListenable: isActiveNotifier,
|
ValueListenableBuilder<String?>(
|
||||||
builder: (_, value, __) {
|
valueListenable: zipcodeError,
|
||||||
return _FormField(
|
builder: (_, zipcodeErrorText, __) {
|
||||||
label: '사용여부',
|
return _FormField(
|
||||||
child: Row(
|
label: '우편번호',
|
||||||
children: [
|
child: Column(
|
||||||
ShadSwitch(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
value: value,
|
children: [
|
||||||
onChanged: saving.value
|
Row(
|
||||||
? null
|
children: [
|
||||||
: (next) => isActiveNotifier.value = next,
|
Expanded(
|
||||||
|
child: ShadInput(
|
||||||
|
controller: zipcodeController,
|
||||||
|
placeholder: const Text('예: 06000'),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ShadButton.outline(
|
||||||
|
onPressed: isSaving
|
||||||
|
? null
|
||||||
|
: () => openPostalSearch(context),
|
||||||
|
child: const Text('검색'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ValueListenableBuilder<PostalSearchResult?>(
|
||||||
|
valueListenable: selectedPostalNotifier,
|
||||||
|
builder: (_, selection, __) {
|
||||||
|
if (selection == null) {
|
||||||
|
return Text(
|
||||||
|
'검색 버튼을 눌러 주소를 선택하세요.',
|
||||||
|
style: theme.textTheme.small.copyWith(
|
||||||
|
color: theme.colorScheme.mutedForeground,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final fullAddress = selection.fullAddress;
|
||||||
|
return Text(
|
||||||
|
fullAddress.isEmpty
|
||||||
|
? '선택한 우편번호에 주소 정보가 없습니다.'
|
||||||
|
: fullAddress,
|
||||||
|
style: theme.textTheme.small,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (zipcodeErrorText != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Text(
|
||||||
|
zipcodeErrorText,
|
||||||
|
style: theme.textTheme.small.copyWith(
|
||||||
|
color: materialTheme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
],
|
||||||
Text(value ? '사용' : '미사용'),
|
),
|
||||||
],
|
);
|
||||||
),
|
},
|
||||||
);
|
),
|
||||||
},
|
const SizedBox(height: 16),
|
||||||
|
_FormField(
|
||||||
|
label: '상세주소',
|
||||||
|
child: ShadInput(
|
||||||
|
controller: addressController,
|
||||||
|
placeholder: const Text('상세주소 입력'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
_FormField(
|
const SizedBox(height: 16),
|
||||||
label: '비고',
|
ValueListenableBuilder<bool>(
|
||||||
child: ShadTextarea(controller: noteController),
|
valueListenable: isActiveNotifier,
|
||||||
),
|
builder: (_, value, __) {
|
||||||
if (existing != null) ..._buildAuditInfo(existing, theme),
|
return _FormField(
|
||||||
],
|
label: '사용여부',
|
||||||
),
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ShadSwitch(
|
||||||
|
value: value,
|
||||||
|
onChanged: isSaving
|
||||||
|
? null
|
||||||
|
: (next) => isActiveNotifier.value = next,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(value ? '사용' : '미사용'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_FormField(
|
||||||
|
label: '비고',
|
||||||
|
child: ShadTextarea(controller: noteController),
|
||||||
|
),
|
||||||
|
if (existing != null) ..._buildAuditInfo(existing, theme),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
|
zipcodeController.removeListener(handleZipcodeChange);
|
||||||
|
selectedPostalNotifier.removeListener(handlePostalSelectionChange);
|
||||||
|
|
||||||
codeController.dispose();
|
codeController.dispose();
|
||||||
nameController.dispose();
|
nameController.dispose();
|
||||||
@@ -920,27 +955,26 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
|||||||
codeError.dispose();
|
codeError.dispose();
|
||||||
nameError.dispose();
|
nameError.dispose();
|
||||||
typeError.dispose();
|
typeError.dispose();
|
||||||
|
zipcodeError.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _confirmDelete(Customer customer) async {
|
Future<void> _confirmDelete(Customer customer) async {
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await SuperportDialog.show<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
dialog: SuperportDialog(
|
||||||
return AlertDialog(
|
title: '고객사 삭제',
|
||||||
title: const Text('고객사 삭제'),
|
description: '"${customer.customerName}" 고객사를 삭제하시겠습니까?',
|
||||||
content: Text('"${customer.customerName}" 고객사를 삭제하시겠습니까?'),
|
actions: [
|
||||||
actions: [
|
ShadButton.ghost(
|
||||||
TextButton(
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
child: const Text('취소'),
|
||||||
child: const Text('취소'),
|
),
|
||||||
),
|
ShadButton(
|
||||||
TextButton(
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
child: const Text('삭제'),
|
||||||
child: const Text('삭제'),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed == true && customer.id != null) {
|
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/core/constants/app_sections.dart';
|
||||||
import 'package:superport_v2/widgets/app_layout.dart';
|
import 'package:superport_v2/widgets/app_layout.dart';
|
||||||
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
||||||
|
import 'package:superport_v2/widgets/components/superport_dialog.dart';
|
||||||
|
|
||||||
import '../../../../../core/config/environment.dart';
|
import '../../../../../core/config/environment.dart';
|
||||||
import '../../../../../widgets/spec_page.dart';
|
import '../../../../../widgets/spec_page.dart';
|
||||||
@@ -130,7 +131,8 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> {
|
|||||||
? false
|
? false
|
||||||
: (result.page * result.pageSize) < result.total;
|
: (result.page * result.pageSize) < result.total;
|
||||||
|
|
||||||
final showReset = _searchController.text.isNotEmpty ||
|
final showReset =
|
||||||
|
_searchController.text.isNotEmpty ||
|
||||||
_controller.defaultFilter != GroupDefaultFilter.all ||
|
_controller.defaultFilter != GroupDefaultFilter.all ||
|
||||||
_controller.statusFilter != GroupStatusFilter.all;
|
_controller.statusFilter != GroupStatusFilter.all;
|
||||||
|
|
||||||
@@ -145,12 +147,35 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> {
|
|||||||
actions: [
|
actions: [
|
||||||
ShadButton(
|
ShadButton(
|
||||||
leading: const Icon(LucideIcons.plus, size: 16),
|
leading: const Icon(LucideIcons.plus, size: 16),
|
||||||
onPressed:
|
onPressed: _controller.isSubmitting
|
||||||
_controller.isSubmitting ? null : () => _openGroupForm(context),
|
? null
|
||||||
|
: () => _openGroupForm(context),
|
||||||
child: const Text('신규 등록'),
|
child: const Text('신규 등록'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
toolbar: FilterBar(
|
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: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 260,
|
width: 260,
|
||||||
@@ -206,28 +231,6 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> {
|
|||||||
.toList(),
|
.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(
|
child: ShadCard(
|
||||||
@@ -272,26 +275,22 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> {
|
|||||||
child: Center(child: CircularProgressIndicator()),
|
child: Center(child: CircularProgressIndicator()),
|
||||||
)
|
)
|
||||||
: groups.isEmpty
|
: groups.isEmpty
|
||||||
? Padding(
|
? Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
child: Text(
|
child: Text(
|
||||||
'조건에 맞는 그룹이 없습니다.',
|
'조건에 맞는 그룹이 없습니다.',
|
||||||
style: theme.textTheme.muted,
|
style: theme.textTheme.muted,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: _GroupTable(
|
: _GroupTable(
|
||||||
groups: groups,
|
groups: groups,
|
||||||
dateFormat: _dateFormat,
|
dateFormat: _dateFormat,
|
||||||
onEdit: _controller.isSubmitting
|
onEdit: _controller.isSubmitting
|
||||||
? null
|
? null
|
||||||
: (group) => _openGroupForm(context, group: group),
|
: (group) => _openGroupForm(context, group: group),
|
||||||
onDelete: _controller.isSubmitting
|
onDelete: _controller.isSubmitting ? null : _confirmDelete,
|
||||||
? null
|
onRestore: _controller.isSubmitting ? null : _restoreGroup,
|
||||||
: _confirmDelete,
|
),
|
||||||
onRestore: _controller.isSubmitting
|
|
||||||
? null
|
|
||||||
: _restoreGroup,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -352,199 +351,185 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> {
|
|||||||
final saving = ValueNotifier<bool>(false);
|
final saving = ValueNotifier<bool>(false);
|
||||||
final nameError = ValueNotifier<String?>(null);
|
final nameError = ValueNotifier<String?>(null);
|
||||||
|
|
||||||
await showDialog<bool>(
|
await SuperportDialog.show<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
dialog: SuperportDialog(
|
||||||
final theme = ShadTheme.of(dialogContext);
|
title: isEdit ? '그룹 수정' : '그룹 등록',
|
||||||
final materialTheme = Theme.of(dialogContext);
|
description: '그룹 정보를 ${isEdit ? '수정' : '입력'}하세요.',
|
||||||
final navigator = Navigator.of(dialogContext);
|
constraints: const BoxConstraints(maxWidth: 540),
|
||||||
return Dialog(
|
actions: [
|
||||||
insetPadding: const EdgeInsets.all(24),
|
ValueListenableBuilder<bool>(
|
||||||
clipBehavior: Clip.antiAlias,
|
valueListenable: saving,
|
||||||
child: ConstrainedBox(
|
builder: (dialogContext, isSaving, __) {
|
||||||
constraints: const BoxConstraints(maxWidth: 540),
|
return ShadButton.ghost(
|
||||||
child: ShadCard(
|
onPressed: isSaving
|
||||||
title: Text(
|
? null
|
||||||
isEdit ? '그룹 수정' : '그룹 등록',
|
: () => Navigator.of(dialogContext).pop(),
|
||||||
style: theme.textTheme.h3,
|
child: const Text('취소'),
|
||||||
),
|
);
|
||||||
description: Text(
|
},
|
||||||
'그룹 정보를 ${isEdit ? '수정' : '입력'}하세요.',
|
),
|
||||||
style: theme.textTheme.muted,
|
ValueListenableBuilder<bool>(
|
||||||
),
|
valueListenable: saving,
|
||||||
footer: ValueListenableBuilder<bool>(
|
builder: (dialogContext, isSaving, __) {
|
||||||
valueListenable: saving,
|
return ShadButton(
|
||||||
builder: (_, isSaving, __) {
|
onPressed: isSaving
|
||||||
return Row(
|
? null
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
: () async {
|
||||||
children: [
|
final name = nameController.text.trim();
|
||||||
ShadButton.ghost(
|
final description = descriptionController.text.trim();
|
||||||
onPressed: isSaving ? null : () => navigator.pop(false),
|
final note = noteController.text.trim();
|
||||||
child: const Text('취소'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
ShadButton(
|
|
||||||
onPressed: isSaving
|
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
final name = nameController.text.trim();
|
|
||||||
final description = descriptionController.text
|
|
||||||
.trim();
|
|
||||||
final note = noteController.text.trim();
|
|
||||||
|
|
||||||
nameError.value = name.isEmpty
|
nameError.value = name.isEmpty ? '그룹명을 입력하세요.' : null;
|
||||||
? '그룹명을 입력하세요.'
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (nameError.value != null) {
|
if (nameError.value != null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
final input = GroupInput(
|
final input = GroupInput(
|
||||||
groupName: name,
|
groupName: name,
|
||||||
description: description.isEmpty
|
description: description.isEmpty ? null : description,
|
||||||
? null
|
isDefault: isDefaultNotifier.value,
|
||||||
: description,
|
isActive: isActiveNotifier.value,
|
||||||
isDefault: isDefaultNotifier.value,
|
note: note.isEmpty ? null : note,
|
||||||
isActive: isActiveNotifier.value,
|
);
|
||||||
note: note.isEmpty ? null : note,
|
final navigator = Navigator.of(dialogContext);
|
||||||
);
|
final response = isEdit
|
||||||
final response = isEdit
|
? await _controller.update(groupId!, input)
|
||||||
? await _controller.update(groupId!, input)
|
: await _controller.create(input);
|
||||||
: await _controller.create(input);
|
saving.value = false;
|
||||||
saving.value = false;
|
if (response != null) {
|
||||||
if (response != null) {
|
if (!navigator.mounted) {
|
||||||
if (!navigator.mounted) {
|
return;
|
||||||
return;
|
}
|
||||||
|
if (mounted) {
|
||||||
|
_showSnack(isEdit ? '그룹을 수정했습니다.' : '그룹을 등록했습니다.');
|
||||||
|
}
|
||||||
|
navigator.pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(isEdit ? '저장' : '등록'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
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),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ValueListenableBuilder<String?>(
|
||||||
|
valueListenable: nameError,
|
||||||
|
builder: (_, errorText, __) {
|
||||||
|
return _FormField(
|
||||||
|
label: '그룹명',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ShadInput(
|
||||||
|
controller: nameController,
|
||||||
|
readOnly: isEdit,
|
||||||
|
onChanged: (_) {
|
||||||
|
if (nameController.text.trim().isNotEmpty) {
|
||||||
|
nameError.value = null;
|
||||||
}
|
}
|
||||||
if (mounted) {
|
},
|
||||||
_showSnack(
|
),
|
||||||
isEdit ? '그룹을 수정했습니다.' : '그룹을 등록했습니다.',
|
if (errorText != null)
|
||||||
);
|
Padding(
|
||||||
}
|
padding: const EdgeInsets.only(top: 6),
|
||||||
navigator.pop(true);
|
child: Text(
|
||||||
}
|
errorText,
|
||||||
},
|
style: theme.textTheme.small.copyWith(
|
||||||
child: Text(isEdit ? '저장' : '등록'),
|
color: materialTheme.colorScheme.error,
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
child: SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.only(right: 12),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
ValueListenableBuilder<String?>(
|
|
||||||
valueListenable: nameError,
|
|
||||||
builder: (_, errorText, __) {
|
|
||||||
return _FormField(
|
|
||||||
label: '그룹명',
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
ShadInput(
|
|
||||||
controller: nameController,
|
|
||||||
readOnly: isEdit,
|
|
||||||
onChanged: (_) {
|
|
||||||
if (nameController.text.trim().isNotEmpty) {
|
|
||||||
nameError.value = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (errorText != null)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 6),
|
|
||||||
child: Text(
|
|
||||||
errorText,
|
|
||||||
style: theme.textTheme.small.copyWith(
|
|
||||||
color: materialTheme.colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_FormField(
|
|
||||||
label: '설명',
|
|
||||||
child: ShadTextarea(controller: descriptionController),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ValueListenableBuilder<bool>(
|
|
||||||
valueListenable: isDefaultNotifier,
|
|
||||||
builder: (_, value, __) {
|
|
||||||
return _FormField(
|
|
||||||
label: '기본여부',
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
ShadSwitch(
|
|
||||||
value: value,
|
|
||||||
onChanged: saving.value
|
|
||||||
? null
|
|
||||||
: (next) =>
|
|
||||||
isDefaultNotifier.value = next,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
],
|
||||||
Text(value ? '기본 그룹' : '일반 그룹'),
|
),
|
||||||
],
|
);
|
||||||
),
|
},
|
||||||
);
|
),
|
||||||
},
|
const SizedBox(height: 16),
|
||||||
|
_FormField(
|
||||||
|
label: '설명',
|
||||||
|
child: ShadTextarea(controller: descriptionController),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: isDefaultNotifier,
|
||||||
|
builder: (_, value, __) {
|
||||||
|
return _FormField(
|
||||||
|
label: '기본여부',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ShadSwitch(
|
||||||
|
value: value,
|
||||||
|
onChanged: saving.value
|
||||||
|
? null
|
||||||
|
: (next) => isDefaultNotifier.value = next,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(value ? '기본 그룹' : '일반 그룹'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: isActiveNotifier,
|
||||||
|
builder: (_, value, __) {
|
||||||
|
return _FormField(
|
||||||
|
label: '사용여부',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ShadSwitch(
|
||||||
|
value: value,
|
||||||
|
onChanged: saving.value
|
||||||
|
? null
|
||||||
|
: (next) => isActiveNotifier.value = next,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(value ? '사용' : '미사용'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_FormField(
|
||||||
|
label: '비고',
|
||||||
|
child: ShadTextarea(controller: noteController),
|
||||||
|
),
|
||||||
|
if (existingGroup != null) ...[
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
'생성일시: ${_formatDateTime(existingGroup.createdAt)}',
|
||||||
|
style: theme.textTheme.small,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 4),
|
||||||
ValueListenableBuilder<bool>(
|
Text(
|
||||||
valueListenable: isActiveNotifier,
|
'수정일시: ${_formatDateTime(existingGroup.updatedAt)}',
|
||||||
builder: (_, value, __) {
|
style: theme.textTheme.small,
|
||||||
return _FormField(
|
|
||||||
label: '사용여부',
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
ShadSwitch(
|
|
||||||
value: value,
|
|
||||||
onChanged: saving.value
|
|
||||||
? null
|
|
||||||
: (next) => isActiveNotifier.value = next,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(value ? '사용' : '미사용'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
|
||||||
_FormField(
|
|
||||||
label: '비고',
|
|
||||||
child: ShadTextarea(controller: noteController),
|
|
||||||
),
|
|
||||||
if (isEdit) ...[
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
Text(
|
|
||||||
'생성일시: ${_formatDateTime(existingGroup.createdAt)}',
|
|
||||||
style: theme.textTheme.small,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'수정일시: ${_formatDateTime(existingGroup.updatedAt)}',
|
|
||||||
style: theme.textTheme.small,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
nameController.dispose();
|
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/core/constants/app_sections.dart';
|
||||||
import 'package:superport_v2/widgets/app_layout.dart';
|
import 'package:superport_v2/widgets/app_layout.dart';
|
||||||
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
||||||
|
import 'package:superport_v2/widgets/components/superport_dialog.dart';
|
||||||
|
|
||||||
import '../../../../../core/config/environment.dart';
|
import '../../../../../core/config/environment.dart';
|
||||||
import '../../../../../widgets/spec_page.dart';
|
import '../../../../../widgets/spec_page.dart';
|
||||||
@@ -167,7 +168,8 @@ class _GroupPermissionEnabledPageState
|
|||||||
? false
|
? false
|
||||||
: (result.page * result.pageSize) < result.total;
|
: (result.page * result.pageSize) < result.total;
|
||||||
|
|
||||||
final showReset = _searchController.text.isNotEmpty ||
|
final showReset =
|
||||||
|
_searchController.text.isNotEmpty ||
|
||||||
_controller.groupFilter != null ||
|
_controller.groupFilter != null ||
|
||||||
_controller.menuFilter != null ||
|
_controller.menuFilter != null ||
|
||||||
_controller.statusFilter != GroupPermissionStatusFilter.all ||
|
_controller.statusFilter != GroupPermissionStatusFilter.all ||
|
||||||
@@ -191,6 +193,29 @@ class _GroupPermissionEnabledPageState
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
toolbar: FilterBar(
|
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: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 260,
|
width: 260,
|
||||||
@@ -208,16 +233,12 @@ class _GroupPermissionEnabledPageState
|
|||||||
key: ValueKey(_controller.groupFilter),
|
key: ValueKey(_controller.groupFilter),
|
||||||
initialValue: _controller.groupFilter,
|
initialValue: _controller.groupFilter,
|
||||||
placeholder: Text(
|
placeholder: Text(
|
||||||
_controller.groups.isEmpty
|
_controller.groups.isEmpty ? '그룹 로딩중...' : '그룹 전체',
|
||||||
? '그룹 로딩중...'
|
|
||||||
: '그룹 전체',
|
|
||||||
),
|
),
|
||||||
selectedOptionBuilder: (context, value) {
|
selectedOptionBuilder: (context, value) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return Text(
|
return Text(
|
||||||
_controller.groups.isEmpty
|
_controller.groups.isEmpty ? '그룹 로딩중...' : '그룹 전체',
|
||||||
? '그룹 로딩중...'
|
|
||||||
: '그룹 전체',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final group = _controller.groups.firstWhere(
|
final group = _controller.groups.firstWhere(
|
||||||
@@ -230,10 +251,7 @@ class _GroupPermissionEnabledPageState
|
|||||||
_controller.updateGroupFilter(value);
|
_controller.updateGroupFilter(value);
|
||||||
},
|
},
|
||||||
options: [
|
options: [
|
||||||
const ShadOption<int?>(
|
const ShadOption<int?>(value: null, child: Text('그룹 전체')),
|
||||||
value: null,
|
|
||||||
child: Text('그룹 전체'),
|
|
||||||
),
|
|
||||||
..._controller.groups.map(
|
..._controller.groups.map(
|
||||||
(group) => ShadOption<int?>(
|
(group) => ShadOption<int?>(
|
||||||
value: group.id,
|
value: group.id,
|
||||||
@@ -249,25 +267,18 @@ class _GroupPermissionEnabledPageState
|
|||||||
key: ValueKey(_controller.menuFilter),
|
key: ValueKey(_controller.menuFilter),
|
||||||
initialValue: _controller.menuFilter,
|
initialValue: _controller.menuFilter,
|
||||||
placeholder: Text(
|
placeholder: Text(
|
||||||
_controller.menus.isEmpty
|
_controller.menus.isEmpty ? '메뉴 로딩중...' : '메뉴 전체',
|
||||||
? '메뉴 로딩중...'
|
|
||||||
: '메뉴 전체',
|
|
||||||
),
|
),
|
||||||
selectedOptionBuilder: (context, value) {
|
selectedOptionBuilder: (context, value) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return Text(
|
return Text(
|
||||||
_controller.menus.isEmpty
|
_controller.menus.isEmpty ? '메뉴 로딩중...' : '메뉴 전체',
|
||||||
? '메뉴 로딩중...'
|
|
||||||
: '메뉴 전체',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final menuItem = _controller.menus.firstWhere(
|
final menuItem = _controller.menus.firstWhere(
|
||||||
(m) => m.id == value,
|
(m) => m.id == value,
|
||||||
orElse: () => MenuItem(
|
orElse: () =>
|
||||||
id: value,
|
MenuItem(id: value, menuCode: '', menuName: ''),
|
||||||
menuCode: '',
|
|
||||||
menuName: '',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return Text(menuItem.menuName);
|
return Text(menuItem.menuName);
|
||||||
},
|
},
|
||||||
@@ -275,10 +286,7 @@ class _GroupPermissionEnabledPageState
|
|||||||
_controller.updateMenuFilter(value);
|
_controller.updateMenuFilter(value);
|
||||||
},
|
},
|
||||||
options: [
|
options: [
|
||||||
const ShadOption<int?>(
|
const ShadOption<int?>(value: null, child: Text('메뉴 전체')),
|
||||||
value: null,
|
|
||||||
child: Text('메뉴 전체'),
|
|
||||||
),
|
|
||||||
..._controller.menus.map(
|
..._controller.menus.map(
|
||||||
(menuItem) => ShadOption<int?>(
|
(menuItem) => ShadOption<int?>(
|
||||||
value: menuItem.id,
|
value: menuItem.id,
|
||||||
@@ -322,24 +330,6 @@ class _GroupPermissionEnabledPageState
|
|||||||
const Text('삭제 포함'),
|
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(
|
child: ShadCard(
|
||||||
@@ -384,27 +374,27 @@ class _GroupPermissionEnabledPageState
|
|||||||
child: Center(child: CircularProgressIndicator()),
|
child: Center(child: CircularProgressIndicator()),
|
||||||
)
|
)
|
||||||
: permissions.isEmpty
|
: permissions.isEmpty
|
||||||
? Padding(
|
? Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
child: Text(
|
child: Text(
|
||||||
'조건에 맞는 권한이 없습니다.',
|
'조건에 맞는 권한이 없습니다.',
|
||||||
style: theme.textTheme.muted,
|
style: theme.textTheme.muted,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: _PermissionTable(
|
: _PermissionTable(
|
||||||
permissions: permissions,
|
permissions: permissions,
|
||||||
dateFormat: _dateFormat,
|
dateFormat: _dateFormat,
|
||||||
onEdit: _controller.isSubmitting
|
onEdit: _controller.isSubmitting
|
||||||
? null
|
? null
|
||||||
: (permission) =>
|
: (permission) => _openPermissionForm(
|
||||||
_openPermissionForm(context, permission: permission),
|
context,
|
||||||
onDelete: _controller.isSubmitting
|
permission: permission,
|
||||||
? null
|
),
|
||||||
: _confirmDelete,
|
onDelete: _controller.isSubmitting ? null : _confirmDelete,
|
||||||
onRestore: _controller.isSubmitting
|
onRestore: _controller.isSubmitting
|
||||||
? null
|
? null
|
||||||
: _restorePermission,
|
: _restorePermission,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -430,311 +420,302 @@ class _GroupPermissionEnabledPageState
|
|||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
GroupPermission? permission,
|
GroupPermission? permission,
|
||||||
}) async {
|
}) async {
|
||||||
final isEdit = permission != null;
|
final existingPermission = permission;
|
||||||
final permissionId = permission?.id;
|
final isEdit = existingPermission != null;
|
||||||
|
final permissionId = existingPermission?.id;
|
||||||
if (isEdit && permissionId == null) {
|
if (isEdit && permissionId == null) {
|
||||||
_showSnack('ID 정보가 없어 수정할 수 없습니다.');
|
_showSnack('ID 정보가 없어 수정할 수 없습니다.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final groupNotifier = ValueNotifier<int?>(permission?.group.id);
|
final groupNotifier = ValueNotifier<int?>(existingPermission?.group.id);
|
||||||
final menuNotifier = ValueNotifier<int?>(permission?.menu.id);
|
final menuNotifier = ValueNotifier<int?>(existingPermission?.menu.id);
|
||||||
final createNotifier = ValueNotifier<bool>(permission?.canCreate ?? false);
|
final createNotifier = ValueNotifier<bool>(
|
||||||
final readNotifier = ValueNotifier<bool>(permission?.canRead ?? true);
|
existingPermission?.canCreate ?? false,
|
||||||
final updateNotifier = ValueNotifier<bool>(permission?.canUpdate ?? false);
|
);
|
||||||
final deleteNotifier = ValueNotifier<bool>(permission?.canDelete ?? false);
|
final readNotifier = ValueNotifier<bool>(
|
||||||
final activeNotifier = ValueNotifier<bool>(permission?.isActive ?? true);
|
existingPermission?.canRead ?? true,
|
||||||
final noteController = TextEditingController(text: permission?.note ?? '');
|
);
|
||||||
|
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 saving = ValueNotifier<bool>(false);
|
||||||
final groupError = ValueNotifier<String?>(null);
|
final groupError = ValueNotifier<String?>(null);
|
||||||
final menuError = ValueNotifier<String?>(null);
|
final menuError = ValueNotifier<String?>(null);
|
||||||
|
|
||||||
await showDialog<bool>(
|
await SuperportDialog.show<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
dialog: SuperportDialog(
|
||||||
final theme = ShadTheme.of(dialogContext);
|
title: isEdit ? '권한 수정' : '권한 등록',
|
||||||
final materialTheme = Theme.of(dialogContext);
|
description: '그룹과 메뉴의 권한을 ${isEdit ? '수정' : '등록'}하세요.',
|
||||||
final navigator = Navigator.of(dialogContext);
|
constraints: const BoxConstraints(maxWidth: 600),
|
||||||
return Dialog(
|
secondaryAction: ValueListenableBuilder<bool>(
|
||||||
insetPadding: const EdgeInsets.all(24),
|
valueListenable: saving,
|
||||||
clipBehavior: Clip.antiAlias,
|
builder: (dialogContext, isSaving, __) {
|
||||||
child: ConstrainedBox(
|
return ShadButton.ghost(
|
||||||
constraints: const BoxConstraints(maxWidth: 600),
|
onPressed: isSaving
|
||||||
child: ShadCard(
|
? null
|
||||||
title: Text(
|
: () => Navigator.of(dialogContext).pop(false),
|
||||||
isEdit ? '권한 수정' : '권한 등록',
|
child: const Text('취소'),
|
||||||
style: theme.textTheme.h3,
|
);
|
||||||
),
|
},
|
||||||
description: Text(
|
),
|
||||||
'그룹과 메뉴의 권한을 ${isEdit ? '수정' : '등록'}하세요.',
|
primaryAction: ValueListenableBuilder<bool>(
|
||||||
style: theme.textTheme.muted,
|
valueListenable: saving,
|
||||||
),
|
builder: (dialogContext, isSaving, __) {
|
||||||
footer: ValueListenableBuilder<bool>(
|
return ShadButton(
|
||||||
valueListenable: saving,
|
onPressed: isSaving
|
||||||
builder: (_, isSaving, __) {
|
? null
|
||||||
return Row(
|
: () async {
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
final groupId = groupNotifier.value;
|
||||||
children: [
|
final menuId = menuNotifier.value;
|
||||||
ShadButton.ghost(
|
groupError.value = groupId == null ? '그룹을 선택하세요.' : null;
|
||||||
onPressed: isSaving ? null : () => navigator.pop(false),
|
menuError.value = menuId == null ? '메뉴를 선택하세요.' : null;
|
||||||
child: const Text('취소'),
|
if (groupError.value != null || menuError.value != null) {
|
||||||
),
|
return;
|
||||||
const SizedBox(width: 12),
|
}
|
||||||
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) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
final input = GroupPermissionInput(
|
final trimmedNote = noteController.text.trim();
|
||||||
groupId: groupId!,
|
final input = GroupPermissionInput(
|
||||||
menuId: menuId!,
|
groupId: groupId!,
|
||||||
canCreate: createNotifier.value,
|
menuId: menuId!,
|
||||||
canRead: readNotifier.value,
|
canCreate: createNotifier.value,
|
||||||
canUpdate: updateNotifier.value,
|
canRead: readNotifier.value,
|
||||||
canDelete: deleteNotifier.value,
|
canUpdate: updateNotifier.value,
|
||||||
isActive: activeNotifier.value,
|
canDelete: deleteNotifier.value,
|
||||||
note: noteController.text.trim().isEmpty
|
isActive: activeNotifier.value,
|
||||||
? null
|
note: trimmedNote.isEmpty ? null : trimmedNote,
|
||||||
: noteController.text.trim(),
|
);
|
||||||
);
|
final navigator = Navigator.of(dialogContext);
|
||||||
final response = isEdit
|
final response = isEdit
|
||||||
? await _controller.update(
|
? await _controller.update(permissionId!, input)
|
||||||
permissionId!,
|
: await _controller.create(input);
|
||||||
input,
|
saving.value = false;
|
||||||
)
|
if (response != null) {
|
||||||
: await _controller.create(input);
|
if (!navigator.mounted) {
|
||||||
saving.value = false;
|
return;
|
||||||
if (response != null) {
|
}
|
||||||
if (!navigator.mounted) {
|
if (mounted) {
|
||||||
return;
|
_showSnack(isEdit ? '권한을 수정했습니다.' : '권한을 등록했습니다.');
|
||||||
}
|
}
|
||||||
if (mounted) {
|
navigator.pop(true);
|
||||||
_showSnack(
|
}
|
||||||
isEdit ? '권한을 수정했습니다.' : '권한을 등록했습니다.',
|
},
|
||||||
);
|
child: Text(isEdit ? '저장' : '등록'),
|
||||||
}
|
);
|
||||||
navigator.pop(true);
|
},
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ValueListenableBuilder<String?>(
|
||||||
|
valueListenable: groupError,
|
||||||
|
builder: (_, errorText, __) {
|
||||||
|
return _FormField(
|
||||||
|
label: '그룹',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ShadSelect<int?>(
|
||||||
|
initialValue: groupNotifier.value,
|
||||||
|
placeholder: const Text('그룹을 선택하세요'),
|
||||||
|
selectedOptionBuilder: (context, value) {
|
||||||
|
if (value == null) {
|
||||||
|
return const Text('그룹을 선택하세요');
|
||||||
}
|
}
|
||||||
|
final groupId = value;
|
||||||
|
final group = _controller.groups.firstWhere(
|
||||||
|
(g) => g.id == groupId,
|
||||||
|
orElse: () =>
|
||||||
|
Group(id: groupId, groupName: ''),
|
||||||
|
);
|
||||||
|
return Text(
|
||||||
|
group.groupName.isEmpty
|
||||||
|
? '그룹을 선택하세요'
|
||||||
|
: group.groupName,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
child: Text(isEdit ? '저장' : '등록'),
|
onChanged: isSaving || isEdit
|
||||||
),
|
? null
|
||||||
],
|
: (value) {
|
||||||
);
|
groupNotifier.value = value;
|
||||||
},
|
if (value != null) {
|
||||||
),
|
groupError.value = null;
|
||||||
child: SizedBox(
|
}
|
||||||
width: double.infinity,
|
},
|
||||||
child: SingleChildScrollView(
|
options: [
|
||||||
padding: const EdgeInsets.only(right: 12),
|
..._controller.groups.map(
|
||||||
child: Column(
|
(group) => ShadOption<int?>(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
value: group.id,
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Text(group.groupName),
|
||||||
children: [
|
|
||||||
ValueListenableBuilder<String?>(
|
|
||||||
valueListenable: groupError,
|
|
||||||
builder: (_, errorText, __) {
|
|
||||||
return _FormField(
|
|
||||||
label: '그룹',
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
ShadSelect<int?>(
|
|
||||||
initialValue: groupNotifier.value,
|
|
||||||
placeholder: const Text('그룹을 선택하세요'),
|
|
||||||
selectedOptionBuilder: (context, value) {
|
|
||||||
if (value == null) {
|
|
||||||
return const Text('그룹을 선택하세요');
|
|
||||||
}
|
|
||||||
final groupId = value;
|
|
||||||
final group = _controller.groups.firstWhere(
|
|
||||||
(g) => g.id == groupId,
|
|
||||||
orElse: () =>
|
|
||||||
Group(id: groupId, groupName: ''),
|
|
||||||
);
|
|
||||||
return Text(
|
|
||||||
group.groupName.isEmpty
|
|
||||||
? '그룹을 선택하세요'
|
|
||||||
: group.groupName,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onChanged: saving.value || isEdit
|
|
||||||
? null
|
|
||||||
: (value) {
|
|
||||||
groupNotifier.value = value;
|
|
||||||
if (value != null) {
|
|
||||||
groupError.value = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
options: [
|
|
||||||
..._controller.groups.map(
|
|
||||||
(group) => ShadOption<int?>(
|
|
||||||
value: group.id,
|
|
||||||
child: Text(group.groupName),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (errorText != null)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 6),
|
|
||||||
child: Text(
|
|
||||||
errorText,
|
|
||||||
style: theme.textTheme.small.copyWith(
|
|
||||||
color: materialTheme.colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
if (errorText != null)
|
||||||
},
|
Padding(
|
||||||
),
|
padding: const EdgeInsets.only(top: 6),
|
||||||
const SizedBox(height: 16),
|
child: Text(
|
||||||
ValueListenableBuilder<String?>(
|
errorText,
|
||||||
valueListenable: menuError,
|
style: theme.textTheme.small.copyWith(
|
||||||
builder: (_, errorText, __) {
|
color: materialTheme.colorScheme.error,
|
||||||
return _FormField(
|
|
||||||
label: '메뉴',
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
ShadSelect<int?>(
|
|
||||||
initialValue: menuNotifier.value,
|
|
||||||
placeholder: const Text('메뉴를 선택하세요'),
|
|
||||||
selectedOptionBuilder: (context, value) {
|
|
||||||
if (value == null) {
|
|
||||||
return const Text('메뉴를 선택하세요');
|
|
||||||
}
|
|
||||||
final menuId = value;
|
|
||||||
final menu = _controller.menus.firstWhere(
|
|
||||||
(m) => m.id == menuId,
|
|
||||||
orElse: () => MenuItem(
|
|
||||||
id: menuId,
|
|
||||||
menuCode: '',
|
|
||||||
menuName: '',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return Text(
|
|
||||||
menu.menuName.isEmpty
|
|
||||||
? '메뉴를 선택하세요'
|
|
||||||
: menu.menuName,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onChanged: saving.value || isEdit
|
|
||||||
? null
|
|
||||||
: (value) {
|
|
||||||
menuNotifier.value = value;
|
|
||||||
if (value != null) {
|
|
||||||
menuError.value = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
options: [
|
|
||||||
..._controller.menus.map(
|
|
||||||
(menu) => ShadOption<int?>(
|
|
||||||
value: menu.id,
|
|
||||||
child: Text(menu.menuName),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (errorText != null)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 6),
|
|
||||||
child: Text(
|
|
||||||
errorText,
|
|
||||||
style: theme.textTheme.small.copyWith(
|
|
||||||
color: materialTheme.colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
_PermissionToggleRow(
|
|
||||||
label: '생성권한',
|
|
||||||
notifier: createNotifier,
|
|
||||||
enabled: !saving.value,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_PermissionToggleRow(
|
|
||||||
label: '조회권한',
|
|
||||||
notifier: readNotifier,
|
|
||||||
enabled: !saving.value,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_PermissionToggleRow(
|
|
||||||
label: '수정권한',
|
|
||||||
notifier: updateNotifier,
|
|
||||||
enabled: !saving.value,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_PermissionToggleRow(
|
|
||||||
label: '삭제권한',
|
|
||||||
notifier: deleteNotifier,
|
|
||||||
enabled: !saving.value,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ValueListenableBuilder<bool>(
|
|
||||||
valueListenable: activeNotifier,
|
|
||||||
builder: (_, value, __) {
|
|
||||||
return _FormField(
|
|
||||||
label: '사용여부',
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
ShadSwitch(
|
|
||||||
value: value,
|
|
||||||
onChanged: saving.value
|
|
||||||
? null
|
|
||||||
: (next) => activeNotifier.value = next,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
),
|
||||||
Text(value ? '사용' : '미사용'),
|
],
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_FormField(
|
|
||||||
label: '비고',
|
|
||||||
child: ShadTextarea(controller: noteController),
|
|
||||||
),
|
|
||||||
if (isEdit) ...[
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
Text(
|
|
||||||
'생성일시: ${_formatDateTime(permission.createdAt)}',
|
|
||||||
style: theme.textTheme.small,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
);
|
||||||
Text(
|
},
|
||||||
'수정일시: ${_formatDateTime(permission.updatedAt)}',
|
|
||||||
style: theme.textTheme.small,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
|
ValueListenableBuilder<String?>(
|
||||||
|
valueListenable: menuError,
|
||||||
|
builder: (_, errorText, __) {
|
||||||
|
return _FormField(
|
||||||
|
label: '메뉴',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ShadSelect<int?>(
|
||||||
|
initialValue: menuNotifier.value,
|
||||||
|
placeholder: const Text('메뉴를 선택하세요'),
|
||||||
|
selectedOptionBuilder: (context, value) {
|
||||||
|
if (value == null) {
|
||||||
|
return const Text('메뉴를 선택하세요');
|
||||||
|
}
|
||||||
|
final menuId = value;
|
||||||
|
final menu = _controller.menus.firstWhere(
|
||||||
|
(m) => m.id == menuId,
|
||||||
|
orElse: () => MenuItem(
|
||||||
|
id: menuId,
|
||||||
|
menuCode: '',
|
||||||
|
menuName: '',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return Text(
|
||||||
|
menu.menuName.isEmpty
|
||||||
|
? '메뉴를 선택하세요'
|
||||||
|
: menu.menuName,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onChanged: isSaving || isEdit
|
||||||
|
? null
|
||||||
|
: (value) {
|
||||||
|
menuNotifier.value = value;
|
||||||
|
if (value != null) {
|
||||||
|
menuError.value = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
..._controller.menus.map(
|
||||||
|
(menu) => ShadOption<int?>(
|
||||||
|
value: menu.id,
|
||||||
|
child: Text(menu.menuName),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (errorText != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Text(
|
||||||
|
errorText,
|
||||||
|
style: theme.textTheme.small.copyWith(
|
||||||
|
color: materialTheme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_PermissionToggleRow(
|
||||||
|
label: '생성권한',
|
||||||
|
notifier: createNotifier,
|
||||||
|
enabled: !isSaving,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PermissionToggleRow(
|
||||||
|
label: '조회권한',
|
||||||
|
notifier: readNotifier,
|
||||||
|
enabled: !isSaving,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PermissionToggleRow(
|
||||||
|
label: '수정권한',
|
||||||
|
notifier: updateNotifier,
|
||||||
|
enabled: !isSaving,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PermissionToggleRow(
|
||||||
|
label: '삭제권한',
|
||||||
|
notifier: deleteNotifier,
|
||||||
|
enabled: !isSaving,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: activeNotifier,
|
||||||
|
builder: (_, value, __) {
|
||||||
|
return _FormField(
|
||||||
|
label: '사용여부',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ShadSwitch(
|
||||||
|
value: value,
|
||||||
|
onChanged: isSaving
|
||||||
|
? null
|
||||||
|
: (next) => activeNotifier.value = next,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(value ? '사용' : '미사용'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_FormField(
|
||||||
|
label: '비고',
|
||||||
|
child: ShadTextarea(controller: noteController),
|
||||||
|
),
|
||||||
|
if (existingPermission != null) ...[
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
'생성일시: ${_formatDateTime(existingPermission.createdAt)}',
|
||||||
|
style: theme.textTheme.small,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'수정일시: ${_formatDateTime(existingPermission.updatedAt)}',
|
||||||
|
style: theme.textTheme.small,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
groupNotifier.dispose();
|
groupNotifier.dispose();
|
||||||
@@ -751,26 +732,29 @@ class _GroupPermissionEnabledPageState
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _confirmDelete(GroupPermission permission) async {
|
Future<void> _confirmDelete(GroupPermission permission) async {
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await SuperportDialog.show<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
dialog: SuperportDialog(
|
||||||
return AlertDialog(
|
title: '권한 삭제',
|
||||||
title: const Text('권한 삭제'),
|
description:
|
||||||
content: Text(
|
|
||||||
'"${permission.group.groupName}" → "${permission.menu.menuName}" 권한을 삭제하시겠습니까?',
|
'"${permission.group.groupName}" → "${permission.menu.menuName}" 권한을 삭제하시겠습니까?',
|
||||||
),
|
secondaryAction: Builder(
|
||||||
actions: [
|
builder: (dialogContext) {
|
||||||
TextButton(
|
return ShadButton.ghost(
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
child: const Text('취소'),
|
child: const Text('취소'),
|
||||||
),
|
);
|
||||||
TextButton(
|
},
|
||||||
|
),
|
||||||
|
primaryAction: Builder(
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return ShadButton.destructive(
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
child: const Text('삭제'),
|
child: const Text('삭제'),
|
||||||
),
|
);
|
||||||
],
|
},
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed == true && permission.id != null) {
|
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/core/constants/app_sections.dart';
|
||||||
import 'package:superport_v2/widgets/app_layout.dart';
|
import 'package:superport_v2/widgets/app_layout.dart';
|
||||||
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
||||||
|
import 'package:superport_v2/widgets/components/superport_dialog.dart';
|
||||||
|
|
||||||
import '../../../../../core/config/environment.dart';
|
import '../../../../../core/config/environment.dart';
|
||||||
import '../../../../../widgets/spec_page.dart';
|
import '../../../../../widgets/spec_page.dart';
|
||||||
@@ -151,7 +152,8 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
|
|||||||
? false
|
? false
|
||||||
: (result.page * result.pageSize) < result.total;
|
: (result.page * result.pageSize) < result.total;
|
||||||
|
|
||||||
final showReset = _searchController.text.isNotEmpty ||
|
final showReset =
|
||||||
|
_searchController.text.isNotEmpty ||
|
||||||
_controller.parentFilter != null ||
|
_controller.parentFilter != null ||
|
||||||
_controller.statusFilter != menu.MenuStatusFilter.all ||
|
_controller.statusFilter != menu.MenuStatusFilter.all ||
|
||||||
_controller.includeDeleted;
|
_controller.includeDeleted;
|
||||||
@@ -167,12 +169,36 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
|
|||||||
actions: [
|
actions: [
|
||||||
ShadButton(
|
ShadButton(
|
||||||
leading: const Icon(LucideIcons.plus, size: 16),
|
leading: const Icon(LucideIcons.plus, size: 16),
|
||||||
onPressed:
|
onPressed: _controller.isSubmitting
|
||||||
_controller.isSubmitting ? null : () => _openMenuForm(context),
|
? null
|
||||||
|
: () => _openMenuForm(context),
|
||||||
child: const Text('신규 등록'),
|
child: const Text('신규 등록'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
toolbar: FilterBar(
|
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: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 260,
|
width: 260,
|
||||||
@@ -195,18 +221,13 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
|
|||||||
selectedOptionBuilder: (context, value) {
|
selectedOptionBuilder: (context, value) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return Text(
|
return Text(
|
||||||
_controller.isLoadingParents
|
_controller.isLoadingParents ? '상위 로딩중...' : '상위 전체',
|
||||||
? '상위 로딩중...'
|
|
||||||
: '상위 전체',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final target = _controller.parents.firstWhere(
|
final target = _controller.parents.firstWhere(
|
||||||
(menuItem) => menuItem.id == value,
|
(menuItem) => menuItem.id == value,
|
||||||
orElse: () => MenuItem(
|
orElse: () =>
|
||||||
id: value,
|
MenuItem(id: value, menuCode: '', menuName: ''),
|
||||||
menuCode: '',
|
|
||||||
menuName: '',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
final label = target.menuName.isEmpty
|
final label = target.menuName.isEmpty
|
||||||
? '상위 전체'
|
? '상위 전체'
|
||||||
@@ -220,10 +241,7 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
|
|||||||
_controller.fetch(page: 1);
|
_controller.fetch(page: 1);
|
||||||
},
|
},
|
||||||
options: [
|
options: [
|
||||||
const ShadOption<int?>(
|
const ShadOption<int?>(value: null, child: Text('상위 전체')),
|
||||||
value: null,
|
|
||||||
child: Text('상위 전체'),
|
|
||||||
),
|
|
||||||
..._controller.parents.map(
|
..._controller.parents.map(
|
||||||
(menuItem) => ShadOption<int?>(
|
(menuItem) => ShadOption<int?>(
|
||||||
value: menuItem.id,
|
value: menuItem.id,
|
||||||
@@ -269,27 +287,6 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
|
|||||||
const Text('삭제 포함'),
|
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(
|
child: ShadCard(
|
||||||
@@ -334,27 +331,22 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
|
|||||||
child: Center(child: CircularProgressIndicator()),
|
child: Center(child: CircularProgressIndicator()),
|
||||||
)
|
)
|
||||||
: menus.isEmpty
|
: menus.isEmpty
|
||||||
? Padding(
|
? Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
child: Text(
|
child: Text(
|
||||||
'조건에 맞는 메뉴가 없습니다.',
|
'조건에 맞는 메뉴가 없습니다.',
|
||||||
style: theme.textTheme.muted,
|
style: theme.textTheme.muted,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: _MenuTable(
|
: _MenuTable(
|
||||||
menus: menus,
|
menus: menus,
|
||||||
dateFormat: _dateFormat,
|
dateFormat: _dateFormat,
|
||||||
onEdit: _controller.isSubmitting
|
onEdit: _controller.isSubmitting
|
||||||
? null
|
? null
|
||||||
: (menuItem) =>
|
: (menuItem) => _openMenuForm(context, menu: menuItem),
|
||||||
_openMenuForm(context, menu: menuItem),
|
onDelete: _controller.isSubmitting ? null : _confirmDelete,
|
||||||
onDelete: _controller.isSubmitting
|
onRestore: _controller.isSubmitting ? null : _restoreMenu,
|
||||||
? null
|
),
|
||||||
: _confirmDelete,
|
|
||||||
onRestore: _controller.isSubmitting
|
|
||||||
? null
|
|
||||||
: _restoreMenu,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -410,302 +402,283 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
|
|||||||
final nameError = ValueNotifier<String?>(null);
|
final nameError = ValueNotifier<String?>(null);
|
||||||
final orderError = ValueNotifier<String?>(null);
|
final orderError = ValueNotifier<String?>(null);
|
||||||
|
|
||||||
await showDialog<bool>(
|
await SuperportDialog.show<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
dialog: SuperportDialog(
|
||||||
final theme = ShadTheme.of(dialogContext);
|
title: isEdit ? '메뉴 수정' : '메뉴 등록',
|
||||||
final materialTheme = Theme.of(dialogContext);
|
description: '메뉴 정보를 ${isEdit ? '수정' : '입력'}하세요.',
|
||||||
final navigator = Navigator.of(dialogContext);
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
return Dialog(
|
secondaryAction: ValueListenableBuilder<bool>(
|
||||||
insetPadding: const EdgeInsets.all(24),
|
valueListenable: saving,
|
||||||
clipBehavior: Clip.antiAlias,
|
builder: (dialogContext, isSaving, __) {
|
||||||
child: ConstrainedBox(
|
return ShadButton.ghost(
|
||||||
constraints: const BoxConstraints(maxWidth: 560),
|
onPressed: isSaving
|
||||||
child: ShadCard(
|
? null
|
||||||
title: Text(
|
: () => Navigator.of(dialogContext).pop(false),
|
||||||
isEdit ? '메뉴 수정' : '메뉴 등록',
|
child: const Text('취소'),
|
||||||
style: theme.textTheme.h3,
|
);
|
||||||
),
|
},
|
||||||
description: Text(
|
),
|
||||||
'메뉴 정보를 ${isEdit ? '수정' : '입력'}하세요.',
|
primaryAction: ValueListenableBuilder<bool>(
|
||||||
style: theme.textTheme.muted,
|
valueListenable: saving,
|
||||||
),
|
builder: (dialogContext, isSaving, __) {
|
||||||
footer: ValueListenableBuilder<bool>(
|
return ShadButton(
|
||||||
valueListenable: saving,
|
onPressed: isSaving
|
||||||
builder: (_, isSaving, __) {
|
? null
|
||||||
return Row(
|
: () async {
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
final code = codeController.text.trim();
|
||||||
children: [
|
final name = nameController.text.trim();
|
||||||
ShadButton.ghost(
|
final path = pathController.text.trim();
|
||||||
onPressed: isSaving ? null : () => navigator.pop(false),
|
final orderText = orderController.text.trim();
|
||||||
child: const Text('취소'),
|
final note = noteController.text.trim();
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
ShadButton(
|
|
||||||
onPressed: isSaving
|
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
final code = codeController.text.trim();
|
|
||||||
final name = nameController.text.trim();
|
|
||||||
final path = pathController.text.trim();
|
|
||||||
final orderText = orderController.text.trim();
|
|
||||||
final note = noteController.text.trim();
|
|
||||||
|
|
||||||
codeError.value = code.isEmpty
|
codeError.value = code.isEmpty ? '메뉴코드를 입력하세요.' : null;
|
||||||
? '메뉴코드를 입력하세요.'
|
nameError.value = name.isEmpty ? '메뉴명을 입력하세요.' : null;
|
||||||
: null;
|
|
||||||
nameError.value = name.isEmpty
|
|
||||||
? '메뉴명을 입력하세요.'
|
|
||||||
: null;
|
|
||||||
|
|
||||||
int? orderValue;
|
int? orderValue;
|
||||||
if (orderText.isNotEmpty) {
|
if (orderText.isNotEmpty) {
|
||||||
orderValue = int.tryParse(orderText);
|
orderValue = int.tryParse(orderText);
|
||||||
if (orderValue == null) {
|
orderError.value = orderValue == null
|
||||||
orderError.value = '표시순서는 숫자여야 합니다.';
|
? '표시순서는 숫자여야 합니다.'
|
||||||
} else {
|
: null;
|
||||||
orderError.value = null;
|
} else {
|
||||||
}
|
orderError.value = null;
|
||||||
} else {
|
}
|
||||||
orderError.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (codeError.value != null ||
|
if (codeError.value != null ||
|
||||||
nameError.value != null ||
|
nameError.value != null ||
|
||||||
orderError.value != null) {
|
orderError.value != null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
final input = MenuInput(
|
final input = MenuInput(
|
||||||
menuCode: code,
|
menuCode: code,
|
||||||
menuName: name,
|
menuName: name,
|
||||||
parentMenuId: parentNotifier.value,
|
parentMenuId: parentNotifier.value,
|
||||||
path: path.isEmpty ? null : path,
|
path: path.isEmpty ? null : path,
|
||||||
displayOrder: orderValue,
|
displayOrder: orderValue,
|
||||||
isActive: isActiveNotifier.value,
|
isActive: isActiveNotifier.value,
|
||||||
note: note.isEmpty ? null : note,
|
note: note.isEmpty ? null : note,
|
||||||
);
|
);
|
||||||
final response = isEdit
|
final navigator = Navigator.of(dialogContext);
|
||||||
? await _controller.update(menuId!, input)
|
final response = isEdit
|
||||||
: await _controller.create(input);
|
? await _controller.update(menuId!, input)
|
||||||
saving.value = false;
|
: await _controller.create(input);
|
||||||
if (response != null) {
|
saving.value = false;
|
||||||
if (!navigator.mounted) {
|
if (response != null) {
|
||||||
return;
|
if (!navigator.mounted) {
|
||||||
}
|
return;
|
||||||
if (mounted) {
|
}
|
||||||
_showSnack(
|
if (mounted) {
|
||||||
isEdit ? '메뉴를 수정했습니다.' : '메뉴를 등록했습니다.',
|
_showSnack(isEdit ? '메뉴를 수정했습니다.' : '메뉴를 등록했습니다.');
|
||||||
);
|
}
|
||||||
}
|
navigator.pop(true);
|
||||||
navigator.pop(true);
|
}
|
||||||
|
},
|
||||||
|
child: Text(isEdit ? '저장' : '등록'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ValueListenableBuilder<String?>(
|
||||||
|
valueListenable: codeError,
|
||||||
|
builder: (_, errorText, __) {
|
||||||
|
return _FormField(
|
||||||
|
label: '메뉴코드',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ShadInput(
|
||||||
|
controller: codeController,
|
||||||
|
readOnly: isEdit,
|
||||||
|
onChanged: (_) {
|
||||||
|
if (codeController.text.trim().isNotEmpty) {
|
||||||
|
codeError.value = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Text(isEdit ? '저장' : '등록'),
|
),
|
||||||
),
|
if (errorText != null)
|
||||||
],
|
Padding(
|
||||||
);
|
padding: const EdgeInsets.only(top: 6),
|
||||||
},
|
child: Text(
|
||||||
),
|
errorText,
|
||||||
child: SizedBox(
|
style: theme.textTheme.small.copyWith(
|
||||||
width: double.infinity,
|
color: materialTheme.colorScheme.error,
|
||||||
child: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.only(right: 12),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
ValueListenableBuilder<String?>(
|
|
||||||
valueListenable: codeError,
|
|
||||||
builder: (_, errorText, __) {
|
|
||||||
return _FormField(
|
|
||||||
label: '메뉴코드',
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
ShadInput(
|
|
||||||
controller: codeController,
|
|
||||||
readOnly: isEdit,
|
|
||||||
onChanged: (_) {
|
|
||||||
if (codeController.text.trim().isNotEmpty) {
|
|
||||||
codeError.value = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (errorText != null)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 6),
|
|
||||||
child: Text(
|
|
||||||
errorText,
|
|
||||||
style: theme.textTheme.small.copyWith(
|
|
||||||
color: materialTheme.colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ValueListenableBuilder<String?>(
|
|
||||||
valueListenable: nameError,
|
|
||||||
builder: (_, errorText, __) {
|
|
||||||
return _FormField(
|
|
||||||
label: '메뉴명',
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
ShadInput(
|
|
||||||
controller: nameController,
|
|
||||||
onChanged: (_) {
|
|
||||||
if (nameController.text.trim().isNotEmpty) {
|
|
||||||
nameError.value = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
if (errorText != null)
|
),
|
||||||
Padding(
|
],
|
||||||
padding: const EdgeInsets.only(top: 6),
|
|
||||||
child: Text(
|
|
||||||
errorText,
|
|
||||||
style: theme.textTheme.small.copyWith(
|
|
||||||
color: materialTheme.colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ValueListenableBuilder<int?>(
|
|
||||||
valueListenable: parentNotifier,
|
|
||||||
builder: (_, value, __) {
|
|
||||||
return _FormField(
|
|
||||||
label: '상위메뉴',
|
|
||||||
child: ShadSelect<int?>(
|
|
||||||
initialValue: value,
|
|
||||||
placeholder: const Text('최상위'),
|
|
||||||
selectedOptionBuilder: (context, selected) {
|
|
||||||
if (selected == null) {
|
|
||||||
return const Text('최상위');
|
|
||||||
}
|
|
||||||
final target = _controller.parents.firstWhere(
|
|
||||||
(item) => item.id == selected,
|
|
||||||
orElse: () => MenuItem(
|
|
||||||
id: selected,
|
|
||||||
menuCode: '',
|
|
||||||
menuName: '',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final label = target.menuName.isEmpty
|
|
||||||
? '최상위'
|
|
||||||
: target.menuName;
|
|
||||||
return Text(label);
|
|
||||||
},
|
|
||||||
onChanged: saving.value
|
|
||||||
? null
|
|
||||||
: (next) => parentNotifier.value = next,
|
|
||||||
options: [
|
|
||||||
const ShadOption<int?>(
|
|
||||||
value: null,
|
|
||||||
child: Text('최상위'),
|
|
||||||
),
|
|
||||||
..._controller.parents
|
|
||||||
.where((item) => item.id != menuId)
|
|
||||||
.map(
|
|
||||||
(menuItem) => ShadOption<int?>(
|
|
||||||
value: menuItem.id,
|
|
||||||
child: Text(menuItem.menuName),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_FormField(
|
|
||||||
label: '경로',
|
|
||||||
child: ShadInput(controller: pathController),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ValueListenableBuilder<String?>(
|
|
||||||
valueListenable: orderError,
|
|
||||||
builder: (_, errorText, __) {
|
|
||||||
return _FormField(
|
|
||||||
label: '표시순서',
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
ShadInput(
|
|
||||||
controller: orderController,
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
),
|
|
||||||
if (errorText != null)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 6),
|
|
||||||
child: Text(
|
|
||||||
errorText,
|
|
||||||
style: theme.textTheme.small.copyWith(
|
|
||||||
color: materialTheme.colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ValueListenableBuilder<bool>(
|
|
||||||
valueListenable: isActiveNotifier,
|
|
||||||
builder: (_, value, __) {
|
|
||||||
return _FormField(
|
|
||||||
label: '사용여부',
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
ShadSwitch(
|
|
||||||
value: value,
|
|
||||||
onChanged: saving.value
|
|
||||||
? null
|
|
||||||
: (next) => isActiveNotifier.value = next,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(value ? '사용' : '미사용'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_FormField(
|
|
||||||
label: '비고',
|
|
||||||
child: ShadTextarea(controller: noteController),
|
|
||||||
),
|
|
||||||
if (isEdit) ...[
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
Text(
|
|
||||||
'생성일시: ${_formatDateTime(existingMenu.createdAt)}',
|
|
||||||
style: theme.textTheme.small,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
);
|
||||||
Text(
|
},
|
||||||
'수정일시: ${_formatDateTime(existingMenu.updatedAt)}',
|
|
||||||
style: theme.textTheme.small,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
|
ValueListenableBuilder<String?>(
|
||||||
|
valueListenable: nameError,
|
||||||
|
builder: (_, errorText, __) {
|
||||||
|
return _FormField(
|
||||||
|
label: '메뉴명',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ShadInput(
|
||||||
|
controller: nameController,
|
||||||
|
onChanged: (_) {
|
||||||
|
if (nameController.text.trim().isNotEmpty) {
|
||||||
|
nameError.value = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (errorText != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Text(
|
||||||
|
errorText,
|
||||||
|
style: theme.textTheme.small.copyWith(
|
||||||
|
color: materialTheme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ValueListenableBuilder<int?>(
|
||||||
|
valueListenable: parentNotifier,
|
||||||
|
builder: (_, value, __) {
|
||||||
|
return _FormField(
|
||||||
|
label: '상위메뉴',
|
||||||
|
child: ShadSelect<int?>(
|
||||||
|
initialValue: value,
|
||||||
|
placeholder: const Text('최상위'),
|
||||||
|
selectedOptionBuilder: (context, selected) {
|
||||||
|
if (selected == null) {
|
||||||
|
return const Text('최상위');
|
||||||
|
}
|
||||||
|
final target = _controller.parents.firstWhere(
|
||||||
|
(item) => item.id == selected,
|
||||||
|
orElse: () => MenuItem(
|
||||||
|
id: selected,
|
||||||
|
menuCode: '',
|
||||||
|
menuName: '',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final label = target.menuName.isEmpty
|
||||||
|
? '최상위'
|
||||||
|
: target.menuName;
|
||||||
|
return Text(label);
|
||||||
|
},
|
||||||
|
onChanged: isSaving
|
||||||
|
? null
|
||||||
|
: (next) => parentNotifier.value = next,
|
||||||
|
options: [
|
||||||
|
const ShadOption<int?>(
|
||||||
|
value: null,
|
||||||
|
child: Text('최상위'),
|
||||||
|
),
|
||||||
|
..._controller.parents
|
||||||
|
.where((item) => item.id != menuId)
|
||||||
|
.map(
|
||||||
|
(menuItem) => ShadOption<int?>(
|
||||||
|
value: menuItem.id,
|
||||||
|
child: Text(menuItem.menuName),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_FormField(
|
||||||
|
label: '경로',
|
||||||
|
child: ShadInput(controller: pathController),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ValueListenableBuilder<String?>(
|
||||||
|
valueListenable: orderError,
|
||||||
|
builder: (_, errorText, __) {
|
||||||
|
return _FormField(
|
||||||
|
label: '표시순서',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ShadInput(
|
||||||
|
controller: orderController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
if (errorText != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Text(
|
||||||
|
errorText,
|
||||||
|
style: theme.textTheme.small.copyWith(
|
||||||
|
color: materialTheme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: isActiveNotifier,
|
||||||
|
builder: (_, value, __) {
|
||||||
|
return _FormField(
|
||||||
|
label: '사용여부',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ShadSwitch(
|
||||||
|
value: value,
|
||||||
|
onChanged: isSaving
|
||||||
|
? null
|
||||||
|
: (next) => isActiveNotifier.value = next,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(value ? '사용' : '미사용'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_FormField(
|
||||||
|
label: '비고',
|
||||||
|
child: ShadTextarea(controller: noteController),
|
||||||
|
),
|
||||||
|
if (existingMenu != null) ...[
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
'생성일시: ${_formatDateTime(existingMenu.createdAt)}',
|
||||||
|
style: theme.textTheme.small,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'수정일시: ${_formatDateTime(existingMenu.updatedAt)}',
|
||||||
|
style: theme.textTheme.small,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
codeController.dispose();
|
codeController.dispose();
|
||||||
@@ -722,24 +695,28 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _confirmDelete(MenuItem menu) async {
|
Future<void> _confirmDelete(MenuItem menu) async {
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await SuperportDialog.show<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
dialog: SuperportDialog(
|
||||||
return AlertDialog(
|
title: '메뉴 삭제',
|
||||||
title: const Text('메뉴 삭제'),
|
description: '"${menu.menuName}" 메뉴를 삭제하시겠습니까?',
|
||||||
content: Text('"${menu.menuName}" 메뉴를 삭제하시겠습니까?'),
|
secondaryAction: Builder(
|
||||||
actions: [
|
builder: (dialogContext) {
|
||||||
TextButton(
|
return ShadButton.ghost(
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
child: const Text('취소'),
|
child: const Text('취소'),
|
||||||
),
|
);
|
||||||
TextButton(
|
},
|
||||||
|
),
|
||||||
|
primaryAction: Builder(
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return ShadButton.destructive(
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
child: const Text('삭제'),
|
child: const Text('삭제'),
|
||||||
),
|
);
|
||||||
],
|
},
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed == true && menu.id != null) {
|
if (confirmed == true && menu.id != null) {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import '../../domain/repositories/product_repository.dart';
|
|||||||
enum ProductStatusFilter { all, activeOnly, inactiveOnly }
|
enum ProductStatusFilter { all, activeOnly, inactiveOnly }
|
||||||
|
|
||||||
class ProductController extends ChangeNotifier {
|
class ProductController extends ChangeNotifier {
|
||||||
|
static const int defaultPageSize = 20;
|
||||||
|
|
||||||
ProductController({
|
ProductController({
|
||||||
required ProductRepository productRepository,
|
required ProductRepository productRepository,
|
||||||
required VendorRepository vendorRepository,
|
required VendorRepository vendorRepository,
|
||||||
@@ -31,6 +33,7 @@ class ProductController extends ChangeNotifier {
|
|||||||
int? _vendorFilter;
|
int? _vendorFilter;
|
||||||
int? _uomFilter;
|
int? _uomFilter;
|
||||||
ProductStatusFilter _statusFilter = ProductStatusFilter.all;
|
ProductStatusFilter _statusFilter = ProductStatusFilter.all;
|
||||||
|
int _pageSize = defaultPageSize;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
|
||||||
List<Vendor> _vendorOptions = const [];
|
List<Vendor> _vendorOptions = const [];
|
||||||
@@ -44,6 +47,7 @@ class ProductController extends ChangeNotifier {
|
|||||||
int? get vendorFilter => _vendorFilter;
|
int? get vendorFilter => _vendorFilter;
|
||||||
int? get uomFilter => _uomFilter;
|
int? get uomFilter => _uomFilter;
|
||||||
ProductStatusFilter get statusFilter => _statusFilter;
|
ProductStatusFilter get statusFilter => _statusFilter;
|
||||||
|
int get pageSize => _pageSize;
|
||||||
String? get errorMessage => _errorMessage;
|
String? get errorMessage => _errorMessage;
|
||||||
List<Vendor> get vendorOptions => _vendorOptions;
|
List<Vendor> get vendorOptions => _vendorOptions;
|
||||||
List<Uom> get uomOptions => _uomOptions;
|
List<Uom> get uomOptions => _uomOptions;
|
||||||
@@ -60,13 +64,16 @@ class ProductController extends ChangeNotifier {
|
|||||||
};
|
};
|
||||||
final response = await _productRepository.list(
|
final response = await _productRepository.list(
|
||||||
page: page,
|
page: page,
|
||||||
pageSize: _result?.pageSize ?? 20,
|
pageSize: _pageSize,
|
||||||
query: _query.isEmpty ? null : _query,
|
query: _query.isEmpty ? null : _query,
|
||||||
vendorId: _vendorFilter,
|
vendorId: _vendorFilter,
|
||||||
uomId: _uomFilter,
|
uomId: _uomFilter,
|
||||||
isActive: isActive,
|
isActive: isActive,
|
||||||
);
|
);
|
||||||
_result = response;
|
_result = response;
|
||||||
|
if (response.pageSize > 0 && response.pageSize != _pageSize) {
|
||||||
|
_pageSize = response.pageSize;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_errorMessage = e.toString();
|
_errorMessage = e.toString();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -92,25 +99,45 @@ class ProductController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void updateQuery(String value) {
|
void updateQuery(String value) {
|
||||||
|
if (_query == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
_query = value;
|
_query = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateVendorFilter(int? vendorId) {
|
void updateVendorFilter(int? vendorId) {
|
||||||
|
if (_vendorFilter == vendorId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
_vendorFilter = vendorId;
|
_vendorFilter = vendorId;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateUomFilter(int? uomId) {
|
void updateUomFilter(int? uomId) {
|
||||||
|
if (_uomFilter == uomId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
_uomFilter = uomId;
|
_uomFilter = uomId;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateStatusFilter(ProductStatusFilter filter) {
|
void updateStatusFilter(ProductStatusFilter filter) {
|
||||||
|
if (_statusFilter == filter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
_statusFilter = filter;
|
_statusFilter = filter;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void updatePageSize(int size) {
|
||||||
|
if (size <= 0 || _pageSize == size) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_pageSize = size;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<Product?> create(ProductInput input) async {
|
Future<Product?> create(ProductInput input) async {
|
||||||
_setSubmitting(true);
|
_setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,8 @@ enum VendorStatusFilter { all, activeOnly, inactiveOnly }
|
|||||||
/// - 목록/검색/필터/페이지 상태를 관리한다.
|
/// - 목록/검색/필터/페이지 상태를 관리한다.
|
||||||
/// - 생성/수정/삭제/복구 요청을 래핑하여 UI에 알린다.
|
/// - 생성/수정/삭제/복구 요청을 래핑하여 UI에 알린다.
|
||||||
class VendorController extends ChangeNotifier {
|
class VendorController extends ChangeNotifier {
|
||||||
|
static const int defaultPageSize = 20;
|
||||||
|
|
||||||
VendorController({required VendorRepository repository})
|
VendorController({required VendorRepository repository})
|
||||||
: _repository = repository;
|
: _repository = repository;
|
||||||
|
|
||||||
@@ -21,6 +23,7 @@ class VendorController extends ChangeNotifier {
|
|||||||
bool _isSubmitting = false;
|
bool _isSubmitting = false;
|
||||||
String _query = '';
|
String _query = '';
|
||||||
VendorStatusFilter _statusFilter = VendorStatusFilter.all;
|
VendorStatusFilter _statusFilter = VendorStatusFilter.all;
|
||||||
|
int _pageSize = defaultPageSize;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
|
||||||
PaginatedResult<Vendor>? get result => _result;
|
PaginatedResult<Vendor>? get result => _result;
|
||||||
@@ -28,6 +31,7 @@ class VendorController extends ChangeNotifier {
|
|||||||
bool get isSubmitting => _isSubmitting;
|
bool get isSubmitting => _isSubmitting;
|
||||||
String get query => _query;
|
String get query => _query;
|
||||||
VendorStatusFilter get statusFilter => _statusFilter;
|
VendorStatusFilter get statusFilter => _statusFilter;
|
||||||
|
int get pageSize => _pageSize;
|
||||||
String? get errorMessage => _errorMessage;
|
String? get errorMessage => _errorMessage;
|
||||||
|
|
||||||
/// 목록 갱신
|
/// 목록 갱신
|
||||||
@@ -43,11 +47,14 @@ class VendorController extends ChangeNotifier {
|
|||||||
};
|
};
|
||||||
final response = await _repository.list(
|
final response = await _repository.list(
|
||||||
page: page,
|
page: page,
|
||||||
pageSize: _result?.pageSize ?? 20,
|
pageSize: _pageSize,
|
||||||
query: _query.isEmpty ? null : _query,
|
query: _query.isEmpty ? null : _query,
|
||||||
isActive: isActive,
|
isActive: isActive,
|
||||||
);
|
);
|
||||||
_result = response;
|
_result = response;
|
||||||
|
if (response.pageSize > 0 && response.pageSize != _pageSize) {
|
||||||
|
_pageSize = response.pageSize;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_errorMessage = e.toString();
|
_errorMessage = e.toString();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -57,15 +64,29 @@ class VendorController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void updateQuery(String value) {
|
void updateQuery(String value) {
|
||||||
|
if (_query == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
_query = value;
|
_query = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateStatusFilter(VendorStatusFilter filter) {
|
void updateStatusFilter(VendorStatusFilter filter) {
|
||||||
|
if (_statusFilter == filter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
_statusFilter = filter;
|
_statusFilter = filter;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void updatePageSize(int size) {
|
||||||
|
if (size <= 0 || _pageSize == size) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_pageSize = size;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// 신규 등록
|
/// 신규 등록
|
||||||
Future<Vendor?> create(VendorInput input) async {
|
Future<Vendor?> create(VendorInput input) async {
|
||||||
_setSubmitting(true);
|
_setSubmitting(true);
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
import 'package:superport_v2/core/constants/app_sections.dart';
|
import 'package:superport_v2/core/constants/app_sections.dart';
|
||||||
import 'package:superport_v2/widgets/app_layout.dart';
|
import 'package:superport_v2/widgets/app_layout.dart';
|
||||||
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
||||||
|
import 'package:superport_v2/widgets/components/superport_dialog.dart';
|
||||||
|
import 'package:superport_v2/widgets/components/superport_table.dart';
|
||||||
|
|
||||||
import '../../../../../core/config/environment.dart';
|
import '../../../../../core/config/environment.dart';
|
||||||
import '../../../../../widgets/spec_page.dart';
|
import '../../../../../widgets/spec_page.dart';
|
||||||
@@ -13,7 +16,9 @@ import '../../../vendor/domain/repositories/vendor_repository.dart';
|
|||||||
import '../controllers/vendor_controller.dart';
|
import '../controllers/vendor_controller.dart';
|
||||||
|
|
||||||
class VendorPage extends StatelessWidget {
|
class VendorPage extends StatelessWidget {
|
||||||
const VendorPage({super.key});
|
const VendorPage({super.key, required this.routeUri});
|
||||||
|
|
||||||
|
final Uri routeUri;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -58,12 +63,14 @@ class VendorPage extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return const _VendorEnabledPage();
|
return _VendorEnabledPage(routeUri: routeUri);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _VendorEnabledPage extends StatefulWidget {
|
class _VendorEnabledPage extends StatefulWidget {
|
||||||
const _VendorEnabledPage();
|
const _VendorEnabledPage({required this.routeUri});
|
||||||
|
|
||||||
|
final Uri routeUri;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_VendorEnabledPage> createState() => _VendorEnabledPageState();
|
State<_VendorEnabledPage> createState() => _VendorEnabledPageState();
|
||||||
@@ -75,13 +82,22 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
|||||||
final FocusNode _searchFocusNode = FocusNode();
|
final FocusNode _searchFocusNode = FocusNode();
|
||||||
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm');
|
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm');
|
||||||
String? _lastError;
|
String? _lastError;
|
||||||
|
bool _routeApplied = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_controller = VendorController(repository: GetIt.I<VendorRepository>());
|
_controller = VendorController(repository: GetIt.I<VendorRepository>())
|
||||||
_controller.addListener(_onControllerChanged);
|
..addListener(_onControllerChanged);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _controller.fetch());
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
if (!_routeApplied) {
|
||||||
|
_routeApplied = true;
|
||||||
|
_applyRouteParameters();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -140,6 +156,32 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
toolbar: FilterBar(
|
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: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 280,
|
width: 280,
|
||||||
@@ -159,10 +201,9 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
|||||||
selectedOptionBuilder: (context, value) =>
|
selectedOptionBuilder: (context, value) =>
|
||||||
Text(_statusLabel(value)),
|
Text(_statusLabel(value)),
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
if (value != null) {
|
if (value == null) return;
|
||||||
_controller.updateStatusFilter(value);
|
_controller.updateStatusFilter(value);
|
||||||
_controller.fetch(page: 1);
|
_updateRoute(page: 1, statusOverride: value);
|
||||||
}
|
|
||||||
},
|
},
|
||||||
options: VendorStatusFilter.values
|
options: VendorStatusFilter.values
|
||||||
.map(
|
.map(
|
||||||
@@ -174,26 +215,6 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
|||||||
.toList(),
|
.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(
|
child: ShadCard(
|
||||||
@@ -217,7 +238,7 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
|||||||
size: ShadButtonSize.sm,
|
size: ShadButtonSize.sm,
|
||||||
onPressed: _controller.isLoading || currentPage <= 1
|
onPressed: _controller.isLoading || currentPage <= 1
|
||||||
? null
|
? null
|
||||||
: () => _controller.fetch(page: currentPage - 1),
|
: () => _goToPage(currentPage - 1),
|
||||||
child: const Text('이전'),
|
child: const Text('이전'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -225,7 +246,7 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
|||||||
size: ShadButtonSize.sm,
|
size: ShadButtonSize.sm,
|
||||||
onPressed: _controller.isLoading || !hasNext
|
onPressed: _controller.isLoading || !hasNext
|
||||||
? null
|
? null
|
||||||
: () => _controller.fetch(page: currentPage + 1),
|
: () => _goToPage(currentPage + 1),
|
||||||
child: const Text('다음'),
|
child: const Text('다음'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -238,27 +259,22 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
|||||||
child: Center(child: CircularProgressIndicator()),
|
child: Center(child: CircularProgressIndicator()),
|
||||||
)
|
)
|
||||||
: vendors.isEmpty
|
: vendors.isEmpty
|
||||||
? Padding(
|
? Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
child: Text(
|
child: Text(
|
||||||
'조건에 맞는 벤더가 없습니다.',
|
'조건에 맞는 벤더가 없습니다.',
|
||||||
style: theme.textTheme.muted,
|
style: theme.textTheme.muted,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: _VendorTable(
|
: _VendorTable(
|
||||||
vendors: vendors,
|
vendors: vendors,
|
||||||
onEdit: _controller.isSubmitting
|
onEdit: _controller.isSubmitting
|
||||||
? null
|
? null
|
||||||
: (vendor) =>
|
: (vendor) => _openVendorForm(context, vendor: vendor),
|
||||||
_openVendorForm(context, vendor: vendor),
|
onDelete: _controller.isSubmitting ? null : _confirmDelete,
|
||||||
onDelete: _controller.isSubmitting
|
onRestore: _controller.isSubmitting ? null : _restoreVendor,
|
||||||
? null
|
dateFormat: _dateFormat,
|
||||||
: _confirmDelete,
|
),
|
||||||
onRestore: _controller.isSubmitting
|
|
||||||
? null
|
|
||||||
: _restoreVendor,
|
|
||||||
dateFormat: _dateFormat,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -266,8 +282,9 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _applyFilters() {
|
void _applyFilters() {
|
||||||
_controller.updateQuery(_searchController.text.trim());
|
final keyword = _searchController.text.trim();
|
||||||
_controller.fetch(page: 1);
|
_controller.updateQuery(keyword);
|
||||||
|
_updateRoute(page: 1, queryOverride: keyword);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _statusLabel(VendorStatusFilter filter) {
|
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 {
|
Future<void> _openVendorForm(BuildContext context, {Vendor? vendor}) async {
|
||||||
final existingVendor = vendor;
|
final existingVendor = vendor;
|
||||||
final isEdit = existingVendor != null;
|
final isEdit = existingVendor != null;
|
||||||
@@ -306,201 +407,183 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
|||||||
final codeError = ValueNotifier<String?>(null);
|
final codeError = ValueNotifier<String?>(null);
|
||||||
final nameError = ValueNotifier<String?>(null);
|
final nameError = ValueNotifier<String?>(null);
|
||||||
|
|
||||||
await showDialog<bool>(
|
await SuperportDialog.show<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
dialog: SuperportDialog(
|
||||||
final theme = ShadTheme.of(dialogContext);
|
title: isEdit ? '벤더 수정' : '벤더 등록',
|
||||||
final materialTheme = Theme.of(dialogContext);
|
description: '벤더 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.',
|
||||||
final navigator = Navigator.of(dialogContext);
|
constraints: const BoxConstraints(maxWidth: 520),
|
||||||
return Dialog(
|
primaryAction: ValueListenableBuilder<bool>(
|
||||||
insetPadding: const EdgeInsets.all(24),
|
valueListenable: saving,
|
||||||
clipBehavior: Clip.antiAlias,
|
builder: (context, isSaving, _) {
|
||||||
child: ConstrainedBox(
|
return ShadButton(
|
||||||
constraints: const BoxConstraints(maxWidth: 520),
|
onPressed: isSaving
|
||||||
child: ShadCard(
|
? null
|
||||||
title: Text(
|
: () async {
|
||||||
isEdit ? '벤더 수정' : '벤더 등록',
|
final code = codeController.text.trim();
|
||||||
style: theme.textTheme.h3,
|
final name = nameController.text.trim();
|
||||||
),
|
final note = noteController.text.trim();
|
||||||
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 note = noteController.text.trim();
|
|
||||||
|
|
||||||
codeError.value = code.isEmpty
|
codeError.value = code.isEmpty ? '벤더코드를 입력하세요.' : null;
|
||||||
? '벤더코드를 입력하세요.'
|
nameError.value = name.isEmpty ? '벤더명을 입력하세요.' : null;
|
||||||
: null;
|
|
||||||
nameError.value = name.isEmpty
|
|
||||||
? '벤더명을 입력하세요.'
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (codeError.value != null ||
|
if (codeError.value != null || nameError.value != null) {
|
||||||
nameError.value != null) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
final input = VendorInput(
|
final input = VendorInput(
|
||||||
vendorCode: code,
|
vendorCode: code,
|
||||||
vendorName: name,
|
vendorName: name,
|
||||||
isActive: isActiveNotifier.value,
|
isActive: isActiveNotifier.value,
|
||||||
note: note.isEmpty ? null : note,
|
note: note.isEmpty ? null : note,
|
||||||
);
|
);
|
||||||
final response = isEdit
|
final navigator = Navigator.of(context);
|
||||||
? await _controller.update(vendorId!, input)
|
final response = isEdit
|
||||||
: await _controller.create(input);
|
? await _controller.update(vendorId!, input)
|
||||||
saving.value = false;
|
: await _controller.create(input);
|
||||||
if (response != null) {
|
saving.value = false;
|
||||||
if (!navigator.mounted) {
|
if (response != null && mounted) {
|
||||||
return;
|
if (!navigator.mounted) {
|
||||||
}
|
return;
|
||||||
if (mounted) {
|
}
|
||||||
_showSnack(
|
_showSnack(isEdit ? '벤더를 수정했습니다.' : '벤더를 등록했습니다.');
|
||||||
isEdit ? '벤더를 수정했습니다.' : '벤더를 등록했습니다.',
|
navigator.pop(true);
|
||||||
);
|
}
|
||||||
}
|
},
|
||||||
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);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ValueListenableBuilder<String?>(
|
||||||
|
valueListenable: codeError,
|
||||||
|
builder: (_, errorText, __) {
|
||||||
|
return _FormField(
|
||||||
|
label: '벤더코드',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ShadInput(
|
||||||
|
controller: codeController,
|
||||||
|
readOnly: isEdit,
|
||||||
|
onChanged: (_) {
|
||||||
|
if (codeController.text.trim().isNotEmpty) {
|
||||||
|
codeError.value = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Text(isEdit ? '저장' : '등록'),
|
),
|
||||||
),
|
if (errorText != null)
|
||||||
],
|
Padding(
|
||||||
);
|
padding: const EdgeInsets.only(top: 6),
|
||||||
},
|
child: Text(
|
||||||
),
|
errorText,
|
||||||
child: Padding(
|
style: theme.textTheme.small.copyWith(
|
||||||
padding: const EdgeInsets.only(right: 12),
|
color: materialTheme.colorScheme.error,
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
ValueListenableBuilder<String?>(
|
|
||||||
valueListenable: codeError,
|
|
||||||
builder: (_, errorText, __) {
|
|
||||||
return _FormField(
|
|
||||||
label: '벤더코드',
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
ShadInput(
|
|
||||||
controller: codeController,
|
|
||||||
readOnly: isEdit,
|
|
||||||
onChanged: (_) {
|
|
||||||
if (codeController.text.trim().isNotEmpty) {
|
|
||||||
codeError.value = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (errorText != null)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 6),
|
|
||||||
child: Text(
|
|
||||||
errorText,
|
|
||||||
style: theme.textTheme.small.copyWith(
|
|
||||||
color: materialTheme.colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ValueListenableBuilder<String?>(
|
|
||||||
valueListenable: nameError,
|
|
||||||
builder: (_, errorText, __) {
|
|
||||||
return _FormField(
|
|
||||||
label: '벤더명',
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
ShadInput(
|
|
||||||
controller: nameController,
|
|
||||||
onChanged: (_) {
|
|
||||||
if (nameController.text.trim().isNotEmpty) {
|
|
||||||
nameError.value = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
if (errorText != null)
|
],
|
||||||
Padding(
|
),
|
||||||
padding: const EdgeInsets.only(top: 6),
|
);
|
||||||
child: Text(
|
},
|
||||||
errorText,
|
),
|
||||||
style: theme.textTheme.small.copyWith(
|
const SizedBox(height: 16),
|
||||||
color: materialTheme.colorScheme.error,
|
ValueListenableBuilder<String?>(
|
||||||
),
|
valueListenable: nameError,
|
||||||
|
builder: (_, errorText, __) {
|
||||||
|
return _FormField(
|
||||||
|
label: '벤더명',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ShadInput(
|
||||||
|
controller: nameController,
|
||||||
|
onChanged: (_) {
|
||||||
|
if (nameController.text.trim().isNotEmpty) {
|
||||||
|
nameError.value = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (errorText != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Text(
|
||||||
|
errorText,
|
||||||
|
style: theme.textTheme.small.copyWith(
|
||||||
|
color: materialTheme.colorScheme.error,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ValueListenableBuilder<bool>(
|
|
||||||
valueListenable: isActiveNotifier,
|
|
||||||
builder: (_, value, __) {
|
|
||||||
return _FormField(
|
|
||||||
label: '사용여부',
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
ShadSwitch(
|
|
||||||
value: value,
|
|
||||||
onChanged: saving.value
|
|
||||||
? null
|
|
||||||
: (next) => isActiveNotifier.value = next,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
],
|
||||||
Text(value ? '사용' : '미사용'),
|
),
|
||||||
],
|
);
|
||||||
),
|
},
|
||||||
);
|
),
|
||||||
},
|
const SizedBox(height: 16),
|
||||||
|
ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: isActiveNotifier,
|
||||||
|
builder: (_, value, __) {
|
||||||
|
return _FormField(
|
||||||
|
label: '사용여부',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ShadSwitch(
|
||||||
|
value: value,
|
||||||
|
onChanged: saving.value
|
||||||
|
? null
|
||||||
|
: (next) => isActiveNotifier.value = next,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(value ? '사용' : '미사용'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_FormField(
|
||||||
|
label: '비고',
|
||||||
|
child: ShadTextarea(controller: noteController),
|
||||||
|
),
|
||||||
|
if (isEdit) ...[
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
'생성일시: ${_formatDateTime(existingVendor.createdAt)}',
|
||||||
|
style: theme.textTheme.small,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 4),
|
||||||
_FormField(
|
Text(
|
||||||
label: '비고',
|
'수정일시: ${_formatDateTime(existingVendor.updatedAt)}',
|
||||||
child: ShadTextarea(controller: noteController),
|
style: theme.textTheme.small,
|
||||||
),
|
),
|
||||||
if (isEdit) ...[
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
Text(
|
|
||||||
'생성일시: ${_formatDateTime(existingVendor.createdAt)}',
|
|
||||||
style: theme.textTheme.small,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'수정일시: ${_formatDateTime(existingVendor.updatedAt)}',
|
|
||||||
style: theme.textTheme.small,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
codeController.dispose();
|
codeController.dispose();
|
||||||
@@ -513,24 +596,22 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _confirmDelete(Vendor vendor) async {
|
Future<void> _confirmDelete(Vendor vendor) async {
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await SuperportDialog.show<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
dialog: SuperportDialog(
|
||||||
return AlertDialog(
|
title: '벤더 삭제',
|
||||||
title: const Text('벤더 삭제'),
|
description: '"${vendor.vendorName}" 벤더를 삭제하시겠습니까?',
|
||||||
content: Text('"${vendor.vendorName}" 벤더를 삭제하시겠습니까?'),
|
actions: [
|
||||||
actions: [
|
ShadButton.ghost(
|
||||||
TextButton(
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
child: const Text('취소'),
|
||||||
child: const Text('취소'),
|
),
|
||||||
),
|
ShadButton(
|
||||||
TextButton(
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
child: const Text('삭제'),
|
||||||
child: const Text('삭제'),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed == true && vendor.id != null) {
|
if (confirmed == true && vendor.id != null) {
|
||||||
@@ -581,39 +662,43 @@ class _VendorTable extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final header = [
|
final columns = const [
|
||||||
'ID',
|
Text('ID'),
|
||||||
'벤더코드',
|
Text('벤더코드'),
|
||||||
'벤더명',
|
Text('벤더명'),
|
||||||
'사용',
|
Text('사용'),
|
||||||
'삭제',
|
Text('삭제'),
|
||||||
'비고',
|
Text('비고'),
|
||||||
'변경일시',
|
Text('변경일시'),
|
||||||
'동작',
|
Text('동작'),
|
||||||
].map((text) => ShadTableCell.header(child: Text(text))).toList();
|
];
|
||||||
|
|
||||||
final rows = vendors.map((vendor) {
|
final rows = vendors.map((vendor) {
|
||||||
return [
|
final cells = <Widget>[
|
||||||
vendor.id?.toString() ?? '-',
|
Text(vendor.id?.toString() ?? '-'),
|
||||||
vendor.vendorCode,
|
Text(vendor.vendorCode),
|
||||||
vendor.vendorName,
|
Text(vendor.vendorName),
|
||||||
vendor.isActive ? 'Y' : 'N',
|
Text(vendor.isActive ? 'Y' : 'N'),
|
||||||
vendor.isDeleted ? 'Y' : '-',
|
Text(vendor.isDeleted ? 'Y' : '-'),
|
||||||
vendor.note?.isEmpty ?? true ? '-' : vendor.note!,
|
Text(vendor.note?.isEmpty ?? true ? '-' : vendor.note!),
|
||||||
vendor.updatedAt == null
|
Text(
|
||||||
? '-'
|
vendor.updatedAt == null
|
||||||
: dateFormat.format(vendor.updatedAt!.toLocal()),
|
? '-'
|
||||||
].map((text) => ShadTableCell(child: Text(text))).toList()..add(
|
: dateFormat.format(vendor.updatedAt!.toLocal()),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
cells.add(
|
||||||
ShadTableCell(
|
ShadTableCell(
|
||||||
child: Row(
|
alignment: Alignment.centerRight,
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
child: Wrap(
|
||||||
|
spacing: 8,
|
||||||
children: [
|
children: [
|
||||||
ShadButton.ghost(
|
ShadButton.ghost(
|
||||||
size: ShadButtonSize.sm,
|
size: ShadButtonSize.sm,
|
||||||
onPressed: onEdit == null ? null : () => onEdit!(vendor),
|
onPressed: onEdit == null ? null : () => onEdit!(vendor),
|
||||||
child: const Icon(LucideIcons.pencil, size: 16),
|
child: const Icon(LucideIcons.pencil, size: 16),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
|
||||||
vendor.isDeleted
|
vendor.isDeleted
|
||||||
? ShadButton.ghost(
|
? ShadButton.ghost(
|
||||||
size: ShadButtonSize.sm,
|
size: ShadButtonSize.sm,
|
||||||
@@ -633,17 +718,18 @@ class _VendorTable extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return cells;
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
return SizedBox(
|
return SuperportTable(
|
||||||
height: 56.0 * (vendors.length + 1),
|
columns: columns,
|
||||||
child: ShadTable.list(
|
rows: rows,
|
||||||
header: header,
|
rowHeight: 56,
|
||||||
children: rows,
|
maxHeight: 520,
|
||||||
columnSpanExtent: (index) => index == 7
|
columnSpanExtent: (index) => index == 7
|
||||||
? const FixedTableSpanExtent(160)
|
? const FixedTableSpanExtent(160)
|
||||||
: const FixedTableSpanExtent(140),
|
: const FixedTableSpanExtent(140),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import '../../domain/repositories/warehouse_repository.dart';
|
|||||||
enum WarehouseStatusFilter { all, activeOnly, inactiveOnly }
|
enum WarehouseStatusFilter { all, activeOnly, inactiveOnly }
|
||||||
|
|
||||||
class WarehouseController extends ChangeNotifier {
|
class WarehouseController extends ChangeNotifier {
|
||||||
|
static const int defaultPageSize = 20;
|
||||||
|
|
||||||
WarehouseController({required WarehouseRepository repository})
|
WarehouseController({required WarehouseRepository repository})
|
||||||
: _repository = repository;
|
: _repository = repository;
|
||||||
|
|
||||||
@@ -17,6 +19,7 @@ class WarehouseController extends ChangeNotifier {
|
|||||||
bool _isSubmitting = false;
|
bool _isSubmitting = false;
|
||||||
String _query = '';
|
String _query = '';
|
||||||
WarehouseStatusFilter _statusFilter = WarehouseStatusFilter.all;
|
WarehouseStatusFilter _statusFilter = WarehouseStatusFilter.all;
|
||||||
|
int _pageSize = defaultPageSize;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
|
||||||
PaginatedResult<Warehouse>? get result => _result;
|
PaginatedResult<Warehouse>? get result => _result;
|
||||||
@@ -24,6 +27,7 @@ class WarehouseController extends ChangeNotifier {
|
|||||||
bool get isSubmitting => _isSubmitting;
|
bool get isSubmitting => _isSubmitting;
|
||||||
String get query => _query;
|
String get query => _query;
|
||||||
WarehouseStatusFilter get statusFilter => _statusFilter;
|
WarehouseStatusFilter get statusFilter => _statusFilter;
|
||||||
|
int get pageSize => _pageSize;
|
||||||
String? get errorMessage => _errorMessage;
|
String? get errorMessage => _errorMessage;
|
||||||
|
|
||||||
Future<void> fetch({int page = 1}) async {
|
Future<void> fetch({int page = 1}) async {
|
||||||
@@ -38,11 +42,14 @@ class WarehouseController extends ChangeNotifier {
|
|||||||
};
|
};
|
||||||
final response = await _repository.list(
|
final response = await _repository.list(
|
||||||
page: page,
|
page: page,
|
||||||
pageSize: _result?.pageSize ?? 20,
|
pageSize: _pageSize,
|
||||||
query: _query.isEmpty ? null : _query,
|
query: _query.isEmpty ? null : _query,
|
||||||
isActive: isActive,
|
isActive: isActive,
|
||||||
);
|
);
|
||||||
_result = response;
|
_result = response;
|
||||||
|
if (response.pageSize > 0 && response.pageSize != _pageSize) {
|
||||||
|
_pageSize = response.pageSize;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_errorMessage = e.toString();
|
_errorMessage = e.toString();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -52,15 +59,29 @@ class WarehouseController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void updateQuery(String value) {
|
void updateQuery(String value) {
|
||||||
|
if (_query == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
_query = value;
|
_query = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateStatusFilter(WarehouseStatusFilter filter) {
|
void updateStatusFilter(WarehouseStatusFilter filter) {
|
||||||
|
if (_statusFilter == filter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
_statusFilter = filter;
|
_statusFilter = filter;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void updatePageSize(int size) {
|
||||||
|
if (size <= 0 || _pageSize == size) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_pageSize = size;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<Warehouse?> create(WarehouseInput input) async {
|
Future<Warehouse?> create(WarehouseInput input) async {
|
||||||
_setSubmitting(true);
|
_setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:intl/intl.dart' as intl;
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
import 'package:superport_v2/core/constants/app_sections.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/features/masters/warehouse/domain/repositories/warehouse_repository.dart';
|
||||||
import 'package:superport_v2/widgets/app_layout.dart';
|
import 'package:superport_v2/widgets/app_layout.dart';
|
||||||
import 'package:superport_v2/widgets/components/empty_state.dart';
|
import 'package:superport_v2/widgets/components/empty_state.dart';
|
||||||
|
import 'package:superport_v2/widgets/components/feedback.dart';
|
||||||
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
||||||
|
import 'package:superport_v2/widgets/components/superport_date_picker.dart';
|
||||||
|
|
||||||
class ReportingPage extends StatefulWidget {
|
class ReportingPage extends StatefulWidget {
|
||||||
const ReportingPage({super.key});
|
const ReportingPage({super.key});
|
||||||
@@ -18,12 +22,16 @@ class ReportingPage extends StatefulWidget {
|
|||||||
|
|
||||||
class _ReportingPageState extends State<ReportingPage> {
|
class _ReportingPageState extends State<ReportingPage> {
|
||||||
late final WarehouseRepository _warehouseRepository;
|
late final WarehouseRepository _warehouseRepository;
|
||||||
final DateFormat _dateFormat = DateFormat('yyyy.MM.dd');
|
final intl.DateFormat _dateFormat = intl.DateFormat('yyyy.MM.dd');
|
||||||
|
|
||||||
DateTimeRange? _dateRange;
|
DateTimeRange? _appliedDateRange;
|
||||||
ReportTypeFilter _selectedType = ReportTypeFilter.all;
|
DateTimeRange? _pendingDateRange;
|
||||||
ReportStatusFilter _selectedStatus = ReportStatusFilter.all;
|
ReportTypeFilter _appliedType = ReportTypeFilter.all;
|
||||||
WarehouseFilterOption _selectedWarehouse = WarehouseFilterOption.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 [
|
List<WarehouseFilterOption> _warehouseOptions = const [
|
||||||
WarehouseFilterOption.all,
|
WarehouseFilterOption.all,
|
||||||
@@ -63,14 +71,14 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_warehouseOptions = options;
|
_warehouseOptions = options;
|
||||||
WarehouseFilterOption nextSelected = WarehouseFilterOption.all;
|
_appliedWarehouse = _resolveWarehouseOption(
|
||||||
for (final option in options) {
|
_appliedWarehouse,
|
||||||
if (option == _selectedWarehouse) {
|
options,
|
||||||
nextSelected = option;
|
);
|
||||||
break;
|
_pendingWarehouse = _resolveWarehouseOption(
|
||||||
}
|
_pendingWarehouse,
|
||||||
}
|
options,
|
||||||
_selectedWarehouse = nextSelected;
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -78,7 +86,8 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_warehouseError = '창고 목록을 불러오지 못했습니다. 잠시 후 다시 시도하세요.';
|
_warehouseError = '창고 목록을 불러오지 못했습니다. 잠시 후 다시 시도하세요.';
|
||||||
_warehouseOptions = const [WarehouseFilterOption.all];
|
_warehouseOptions = const [WarehouseFilterOption.all];
|
||||||
_selectedWarehouse = WarehouseFilterOption.all;
|
_appliedWarehouse = WarehouseFilterOption.all;
|
||||||
|
_pendingWarehouse = WarehouseFilterOption.all;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} 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() {
|
void _resetFilters() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_dateRange = null;
|
_appliedDateRange = null;
|
||||||
_selectedType = ReportTypeFilter.all;
|
_pendingDateRange = null;
|
||||||
_selectedStatus = ReportStatusFilter.all;
|
_appliedType = ReportTypeFilter.all;
|
||||||
_selectedWarehouse = WarehouseFilterOption.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 {
|
bool get _canExport {
|
||||||
return _dateRange != null && _selectedType != ReportTypeFilter.all;
|
return _appliedDateRange != null && _appliedType != ReportTypeFilter.all;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get _hasCustomFilters {
|
bool get _hasCustomFilters {
|
||||||
return _dateRange != null ||
|
return _appliedDateRange != null ||
|
||||||
_selectedType != ReportTypeFilter.all ||
|
_appliedType != ReportTypeFilter.all ||
|
||||||
_selectedStatus != ReportStatusFilter.all ||
|
_appliedStatus != ReportStatusFilter.all ||
|
||||||
_selectedWarehouse != WarehouseFilterOption.all;
|
_appliedWarehouse != WarehouseFilterOption.all;
|
||||||
}
|
}
|
||||||
|
|
||||||
String get _dateRangeLabel {
|
bool get _hasAppliedFilters => _hasCustomFilters;
|
||||||
final range = _dateRange;
|
|
||||||
|
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) {
|
if (range == null) {
|
||||||
return '기간 선택';
|
return '기간 선택';
|
||||||
}
|
}
|
||||||
@@ -150,11 +172,7 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
String _formatDate(DateTime value) => _dateFormat.format(value);
|
String _formatDate(DateTime value) => _dateFormat.format(value);
|
||||||
|
|
||||||
void _handleExport(ReportExportFormat format) {
|
void _handleExport(ReportExportFormat format) {
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
SuperportToast.info(context, '${format.label} 다운로드 연동은 준비 중입니다.');
|
||||||
messenger.clearSnackBars();
|
|
||||||
messenger.showSnackBar(
|
|
||||||
SnackBar(content: Text('${format.label} 다운로드 연동은 준비 중입니다.')),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -174,34 +192,56 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
onPressed: _canExport
|
onPressed: _canExport
|
||||||
? () => _handleExport(ReportExportFormat.xlsx)
|
? () => _handleExport(ReportExportFormat.xlsx)
|
||||||
: null,
|
: null,
|
||||||
leading: const Icon(LucideIcons.fileDown, size: 16),
|
leading: const Icon(lucide.LucideIcons.fileDown, size: 16),
|
||||||
child: const Text('XLSX 다운로드'),
|
child: const Text('XLSX 다운로드'),
|
||||||
),
|
),
|
||||||
ShadButton.outline(
|
ShadButton.outline(
|
||||||
onPressed: _canExport
|
onPressed: _canExport
|
||||||
? () => _handleExport(ReportExportFormat.pdf)
|
? () => _handleExport(ReportExportFormat.pdf)
|
||||||
: null,
|
: null,
|
||||||
leading: const Icon(LucideIcons.fileText, size: 16),
|
leading: const Icon(lucide.LucideIcons.fileText, size: 16),
|
||||||
child: const Text('PDF 다운로드'),
|
child: const Text('PDF 다운로드'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
toolbar: FilterBar(
|
toolbar: FilterBar(
|
||||||
|
actionConfig: FilterBarActionConfig(
|
||||||
|
onApply: _applyFilters,
|
||||||
|
onReset: _resetFilters,
|
||||||
|
hasPendingChanges: _hasDirtyFilters,
|
||||||
|
hasActiveFilters: _hasAppliedFilters,
|
||||||
|
),
|
||||||
children: [
|
children: [
|
||||||
ShadButton.outline(
|
SizedBox(
|
||||||
onPressed: _pickDateRange,
|
width: 220,
|
||||||
leading: const Icon(LucideIcons.calendar, size: 16),
|
child: SuperportDateRangePickerButton(
|
||||||
child: Text(_dateRangeLabel),
|
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(
|
SizedBox(
|
||||||
width: 200,
|
width: 200,
|
||||||
child: ShadSelect<ReportTypeFilter>(
|
child: ShadSelect<ReportTypeFilter>(
|
||||||
key: ValueKey(_selectedType),
|
key: ValueKey(_pendingType),
|
||||||
initialValue: _selectedType,
|
initialValue: _pendingType,
|
||||||
selectedOptionBuilder: (_, value) => Text(value.label),
|
selectedOptionBuilder: (_, value) => Text(value.label),
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
if (value == null) return;
|
if (value == null) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedType = value;
|
_pendingType = value;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
options: [
|
options: [
|
||||||
@@ -214,14 +254,14 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
width: 220,
|
width: 220,
|
||||||
child: ShadSelect<WarehouseFilterOption>(
|
child: ShadSelect<WarehouseFilterOption>(
|
||||||
key: ValueKey(
|
key: ValueKey(
|
||||||
'${_selectedWarehouse.cacheKey}-${_warehouseOptions.length}',
|
'${_pendingWarehouse.cacheKey}-${_warehouseOptions.length}',
|
||||||
),
|
),
|
||||||
initialValue: _selectedWarehouse,
|
initialValue: _pendingWarehouse,
|
||||||
selectedOptionBuilder: (_, value) => Text(value.label),
|
selectedOptionBuilder: (_, value) => Text(value.label),
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
if (value == null) return;
|
if (value == null) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedWarehouse = value;
|
_pendingWarehouse = value;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
options: [
|
options: [
|
||||||
@@ -233,13 +273,13 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: 200,
|
width: 200,
|
||||||
child: ShadSelect<ReportStatusFilter>(
|
child: ShadSelect<ReportStatusFilter>(
|
||||||
key: ValueKey(_selectedStatus),
|
key: ValueKey(_pendingStatus),
|
||||||
initialValue: _selectedStatus,
|
initialValue: _pendingStatus,
|
||||||
selectedOptionBuilder: (_, value) => Text(value.label),
|
selectedOptionBuilder: (_, value) => Text(value.label),
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
if (value == null) return;
|
if (value == null) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedStatus = value;
|
_pendingStatus = value;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
options: [
|
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(
|
child: Column(
|
||||||
@@ -264,7 +299,7 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
LucideIcons.circleAlert,
|
lucide.LucideIcons.circleAlert,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: theme.colorScheme.destructive,
|
color: theme.colorScheme.destructive,
|
||||||
),
|
),
|
||||||
@@ -280,7 +315,7 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
ShadButton.ghost(
|
ShadButton.ghost(
|
||||||
onPressed: _isLoadingWarehouses ? null : _loadWarehouses,
|
onPressed: _isLoadingWarehouses ? null : _loadWarehouses,
|
||||||
leading: const Icon(LucideIcons.refreshCw, size: 16),
|
leading: const Icon(lucide.LucideIcons.refreshCw, size: 16),
|
||||||
child: const Text('재시도'),
|
child: const Text('재시도'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -290,14 +325,12 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: const [
|
||||||
const SizedBox(
|
SuperportSkeleton(width: 180, height: 20),
|
||||||
width: 16,
|
SizedBox(width: 12),
|
||||||
height: 16,
|
SuperportSkeleton(width: 140, height: 20),
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
SizedBox(width: 12),
|
||||||
),
|
SuperportSkeleton(width: 120, height: 20),
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text('창고 목록을 불러오는 중입니다...', style: theme.textTheme.small),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -312,11 +345,13 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
children: [
|
children: [
|
||||||
_SummaryRow(
|
_SummaryRow(
|
||||||
label: '기간',
|
label: '기간',
|
||||||
value: _dateRange == null ? '기간을 선택하세요.' : _dateRangeLabel,
|
value: _appliedDateRange == null
|
||||||
|
? '기간을 선택하세요.'
|
||||||
|
: _dateRangeLabel(_appliedDateRange),
|
||||||
),
|
),
|
||||||
_SummaryRow(label: '유형', value: _selectedType.label),
|
_SummaryRow(label: '유형', value: _appliedType.label),
|
||||||
_SummaryRow(label: '창고', value: _selectedWarehouse.label),
|
_SummaryRow(label: '창고', value: _appliedWarehouse.label),
|
||||||
_SummaryRow(label: '상태', value: _selectedStatus.label),
|
_SummaryRow(label: '상태', value: _appliedStatus.label),
|
||||||
if (!_canExport)
|
if (!_canExport)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 12),
|
padding: const EdgeInsets.only(top: 12),
|
||||||
@@ -339,9 +374,10 @@ class _ReportingPageState extends State<ReportingPage> {
|
|||||||
),
|
),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 240,
|
height: 240,
|
||||||
child: EmptyState(
|
child: SuperportEmptyState(
|
||||||
icon: LucideIcons.chartBar,
|
icon: lucide.LucideIcons.chartBar,
|
||||||
message: '필터를 선택하고 다운로드하면 결과 미리보기가 제공됩니다.',
|
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});
|
const PostalSearchPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PostalSearchPage> createState() => _PostalSearchPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PostalSearchPageState extends State<PostalSearchPage> {
|
||||||
|
PostalSearchResult? _lastSelection;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const SpecPage(
|
final theme = ShadTheme.of(context);
|
||||||
|
|
||||||
|
return AppLayout(
|
||||||
title: '우편번호 검색',
|
title: '우편번호 검색',
|
||||||
summary: '모달 기반 우편번호 검색 UI 구성을 정의합니다.',
|
subtitle: '창고/고객사 등 주소 입력 폼에서 재사용되는 검색 모달입니다.',
|
||||||
sections: [
|
breadcrumbs: const [
|
||||||
SpecSection(
|
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
|
||||||
title: '모달 구성',
|
AppBreadcrumbItem(label: '유틸리티', path: '/utilities/postal-search'),
|
||||||
items: [
|
AppBreadcrumbItem(label: '우편번호 검색'),
|
||||||
'검색어 [Text] 입력 필드',
|
|
||||||
'결과 리스트: 우편번호 | 시도 | 시군구 | 도로명 | 건물번호',
|
|
||||||
'선택 시 호출 화면에 우편번호/주소 전달',
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
|
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) {
|
onRowTap: (index) {
|
||||||
navigator.pop(_results[index]);
|
if (_results.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final adjustedIndex = (index - 1).clamp(
|
||||||
|
0,
|
||||||
|
_results.length - 1,
|
||||||
|
);
|
||||||
|
navigator.pop(_results[adjustedIndex]);
|
||||||
},
|
},
|
||||||
emptyLabel: '검색 결과가 없습니다.',
|
emptyLabel: '검색 결과가 없습니다.',
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import 'package:dio/dio.dart';
|
|||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
|
||||||
import 'core/network/api_client.dart';
|
import 'core/network/api_client.dart';
|
||||||
|
import 'core/network/api_error.dart';
|
||||||
import 'core/network/interceptors/auth_interceptor.dart';
|
import 'core/network/interceptors/auth_interceptor.dart';
|
||||||
|
import 'core/services/token_storage.dart';
|
||||||
import 'features/masters/customer/data/repositories/customer_repository_remote.dart';
|
import 'features/masters/customer/data/repositories/customer_repository_remote.dart';
|
||||||
import 'features/masters/customer/domain/repositories/customer_repository.dart';
|
import 'features/masters/customer/domain/repositories/customer_repository.dart';
|
||||||
import 'features/masters/group/data/repositories/group_repository_remote.dart';
|
import 'features/masters/group/data/repositories/group_repository_remote.dart';
|
||||||
@@ -55,13 +57,20 @@ Future<void> initInjection({
|
|||||||
final dio = Dio(options);
|
final dio = Dio(options);
|
||||||
|
|
||||||
// 인터셉터 등록 (Auth 등)
|
// 인터셉터 등록 (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 등)
|
// 개발용 로거는 필요 시 추가 (pretty_dio_logger 등)
|
||||||
// if (!kReleaseMode) { dio.interceptors.add(PrettyDioLogger(...)); }
|
// if (!kReleaseMode) { dio.interceptors.add(PrettyDioLogger(...)); }
|
||||||
|
|
||||||
// ApiClient 등록
|
// ApiClient 등록
|
||||||
sl.registerLazySingleton<ApiClient>(() => ApiClient(dio: dio));
|
sl.registerLazySingleton<ApiClient>(
|
||||||
|
() => ApiClient(dio: dio, errorMapper: sl<ApiErrorMapper>()),
|
||||||
|
);
|
||||||
|
|
||||||
// 리포지토리 등록 (예: 벤더)
|
// 리포지토리 등록 (예: 벤더)
|
||||||
sl.registerLazySingleton<VendorRepository>(
|
sl.registerLazySingleton<VendorRepository>(
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
|||||||
|
|
||||||
import 'core/config/environment.dart';
|
import 'core/config/environment.dart';
|
||||||
import 'core/routing/app_router.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 'injection_container.dart';
|
||||||
|
import 'core/permissions/permission_manager.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -13,28 +16,56 @@ Future<void> main() async {
|
|||||||
runApp(const SuperportApp());
|
runApp(const SuperportApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
class SuperportApp extends StatelessWidget {
|
class SuperportApp extends StatefulWidget {
|
||||||
const SuperportApp({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ShadApp.router(
|
return PermissionScope(
|
||||||
title: 'Superport v2',
|
manager: _permissionManager,
|
||||||
routerConfig: appRouter,
|
child: ThemeControllerScope(
|
||||||
debugShowCheckedModeBanner: false,
|
controller: _themeController,
|
||||||
supportedLocales: const [Locale('ko', 'KR'), Locale('en', 'US')],
|
child: AnimatedBuilder(
|
||||||
localizationsDelegates: const [
|
animation: _themeController,
|
||||||
GlobalMaterialLocalizations.delegate,
|
builder: (context, _) {
|
||||||
GlobalWidgetsLocalizations.delegate,
|
return ShadApp.router(
|
||||||
GlobalCupertinoLocalizations.delegate,
|
title: 'Superport v2',
|
||||||
],
|
routerConfig: appRouter,
|
||||||
theme: ShadThemeData(
|
debugShowCheckedModeBanner: false,
|
||||||
colorScheme: const ShadSlateColorScheme.light(),
|
supportedLocales: const [Locale('ko', 'KR'), Locale('en', 'US')],
|
||||||
brightness: Brightness.light,
|
localizationsDelegates: const [
|
||||||
),
|
GlobalMaterialLocalizations.delegate,
|
||||||
darkTheme: ShadThemeData(
|
GlobalWidgetsLocalizations.delegate,
|
||||||
colorScheme: const ShadSlateColorScheme.dark(),
|
GlobalCupertinoLocalizations.delegate,
|
||||||
brightness: Brightness.dark,
|
],
|
||||||
|
theme: SuperportShadTheme.light(),
|
||||||
|
darkTheme: SuperportShadTheme.dark(),
|
||||||
|
themeMode: _themeController.mode,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,15 +35,8 @@ class AppLayout extends StatelessWidget {
|
|||||||
_BreadcrumbBar(items: breadcrumbs),
|
_BreadcrumbBar(items: breadcrumbs),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
PageHeader(
|
PageHeader(title: title, subtitle: subtitle, actions: actions),
|
||||||
title: title,
|
if (toolbar != null) ...[const SizedBox(height: 16), toolbar!],
|
||||||
subtitle: subtitle,
|
|
||||||
actions: actions,
|
|
||||||
),
|
|
||||||
if (toolbar != null) ...[
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
toolbar!,
|
|
||||||
],
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
child,
|
child,
|
||||||
],
|
],
|
||||||
@@ -54,11 +47,7 @@ class AppLayout extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class AppBreadcrumbItem {
|
class AppBreadcrumbItem {
|
||||||
const AppBreadcrumbItem({
|
const AppBreadcrumbItem({required this.label, this.path, this.onTap});
|
||||||
required this.label,
|
|
||||||
this.path,
|
|
||||||
this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String label;
|
final String label;
|
||||||
final String? path;
|
final String? path;
|
||||||
@@ -94,7 +83,10 @@ class _BreadcrumbBar extends StatelessWidget {
|
|||||||
size: 14,
|
size: 14,
|
||||||
color: colorScheme.mutedForeground,
|
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(
|
final label = Text(
|
||||||
item.label,
|
item.label,
|
||||||
style: theme.textTheme.small.copyWith(
|
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:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.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/constants/app_sections.dart';
|
||||||
|
import '../core/theme/theme_controller.dart';
|
||||||
|
import '../core/permissions/permission_manager.dart';
|
||||||
|
|
||||||
class AppShell extends StatelessWidget {
|
class AppShell extends StatelessWidget {
|
||||||
const AppShell({
|
const AppShell({
|
||||||
@@ -19,6 +21,13 @@ class AppShell extends StatelessWidget {
|
|||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final isWide = constraints.maxWidth >= 960;
|
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) {
|
if (isWide) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -26,14 +35,14 @@ class AppShell extends StatelessWidget {
|
|||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: '로그아웃',
|
tooltip: '로그아웃',
|
||||||
icon: const Icon(LucideIcons.logOut),
|
icon: const Icon(lucide.LucideIcons.logOut),
|
||||||
onPressed: () => context.go(loginRoutePath),
|
onPressed: () => context.go(loginRoutePath),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Row(
|
body: Row(
|
||||||
children: [
|
children: [
|
||||||
_NavigationRail(currentLocation: currentLocation),
|
_NavigationRail(currentLocation: currentLocation, pages: pages),
|
||||||
const VerticalDivider(width: 1),
|
const VerticalDivider(width: 1),
|
||||||
Expanded(child: child),
|
Expanded(child: child),
|
||||||
],
|
],
|
||||||
@@ -47,7 +56,7 @@ class AppShell extends StatelessWidget {
|
|||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: '로그아웃',
|
tooltip: '로그아웃',
|
||||||
icon: const Icon(LucideIcons.logOut),
|
icon: const Icon(lucide.LucideIcons.logOut),
|
||||||
onPressed: () => context.go(loginRoutePath),
|
onPressed: () => context.go(loginRoutePath),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -60,6 +69,7 @@ class AppShell extends StatelessWidget {
|
|||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
context.go(path);
|
context.go(path);
|
||||||
},
|
},
|
||||||
|
pages: pages,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -71,16 +81,18 @@ class AppShell extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _NavigationRail extends StatelessWidget {
|
class _NavigationRail extends StatelessWidget {
|
||||||
const _NavigationRail({required this.currentLocation});
|
const _NavigationRail({required this.currentLocation, required this.pages});
|
||||||
|
|
||||||
final String currentLocation;
|
final String currentLocation;
|
||||||
|
final List<AppPageDescriptor> pages;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final pages = allAppPages;
|
|
||||||
final selectedIndex = _selectedIndex(currentLocation, pages);
|
final selectedIndex = _selectedIndex(currentLocation, pages);
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
|
final themeController = ThemeControllerScope.of(context);
|
||||||
|
final currentThemeMode = themeController.mode;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
width: 104,
|
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 {
|
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 String currentLocation;
|
||||||
final ValueChanged<String> onTap;
|
final ValueChanged<String> onTap;
|
||||||
|
final List<AppPageDescriptor> pages;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final pages = allAppPages;
|
|
||||||
final selectedIndex = _selectedIndex(currentLocation, pages);
|
final selectedIndex = _selectedIndex(currentLocation, pages);
|
||||||
|
final themeController = ThemeControllerScope.of(context);
|
||||||
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemCount: pages.length,
|
itemCount: pages.length + 1,
|
||||||
itemBuilder: (context, index) {
|
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 page = pages[index];
|
||||||
final selected = index == selectedIndex;
|
final selected = index == selectedIndex;
|
||||||
return ListTile(
|
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) {
|
int _selectedIndex(String location, List<AppPageDescriptor> pages) {
|
||||||
final normalized = location.toLowerCase();
|
final normalized = location.toLowerCase();
|
||||||
final exact = pages.indexWhere(
|
final exact = pages.indexWhere(
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
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 IconData? icon;
|
||||||
|
final Widget? action;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -16,8 +26,17 @@ class EmptyState extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
if (icon != null)
|
if (icon != null)
|
||||||
Icon(icon, size: 48, color: theme.colorScheme.mutedForeground),
|
Icon(icon, size: 48, color: theme.colorScheme.mutedForeground),
|
||||||
if (icon != null) const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(message, style: theme.textTheme.muted),
|
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 {
|
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 List<Widget> children;
|
||||||
|
final String? title;
|
||||||
|
final List<Widget>? actions;
|
||||||
|
final double spacing;
|
||||||
|
final double runSpacing;
|
||||||
|
final FilterBarActionConfig? actionConfig;
|
||||||
|
final Widget? leading;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = ShadTheme.of(context);
|
final theme = ShadTheme.of(context);
|
||||||
|
final computedActions = _resolveActions(context);
|
||||||
|
final hasHeading =
|
||||||
|
(title != null && title!.isNotEmpty) || computedActions.isNotEmpty;
|
||||||
|
|
||||||
return ShadCard(
|
return ShadCard(
|
||||||
title: Text('검색 및 필터', style: theme.textTheme.h3),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
|
||||||
child: Align(
|
child: Column(
|
||||||
alignment: Alignment.centerLeft,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: Wrap(
|
mainAxisSize: MainAxisSize.min,
|
||||||
spacing: 16,
|
children: [
|
||||||
runSpacing: 16,
|
if (hasHeading)
|
||||||
children: children,
|
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(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (leading != null) ...[
|
if (leading != null) ...[leading!, const SizedBox(width: 16)],
|
||||||
leading!,
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
],
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -42,16 +39,9 @@ class PageHeader extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (actions != null && actions!.isNotEmpty) ...[
|
if (actions != null && actions!.isNotEmpty) ...[
|
||||||
Wrap(
|
Wrap(spacing: 12, runSpacing: 12, children: actions!),
|
||||||
spacing: 12,
|
|
||||||
runSpacing: 12,
|
|
||||||
children: actions!,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if (trailing != null) ...[
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
trailing!,
|
|
||||||
],
|
],
|
||||||
|
if (trailing != null) ...[const SizedBox(width: 16), trailing!],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,98 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
const double desktopBreakpoint = 1200;
|
const double desktopBreakpoint = 1200;
|
||||||
const double tabletBreakpoint = 960;
|
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 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 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:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
/// 공통 모달 다이얼로그.
|
import '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>({
|
Future<T?> showSuperportDialog<T>({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
required String title,
|
required String title,
|
||||||
String? description,
|
String? description,
|
||||||
required Widget body,
|
required Widget body,
|
||||||
|
Widget? primaryAction,
|
||||||
|
Widget? secondaryAction,
|
||||||
List<Widget>? actions,
|
List<Widget>? actions,
|
||||||
|
bool mobileFullscreen = true,
|
||||||
bool barrierDismissible = 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,
|
context: context,
|
||||||
barrierDismissible: barrierDismissible,
|
barrierDismissible: barrierDismissible,
|
||||||
builder: (dialogContext) {
|
dialog: SuperportDialog(
|
||||||
final theme = ShadTheme.of(dialogContext);
|
title: title,
|
||||||
return Dialog(
|
description: description,
|
||||||
insetPadding: const EdgeInsets.all(24),
|
primaryAction: primaryAction,
|
||||||
clipBehavior: Clip.antiAlias,
|
secondaryAction: secondaryAction,
|
||||||
child: ShadCard(
|
actions: actions,
|
||||||
title: Text(title, style: theme.textTheme.h3),
|
constraints: constraints,
|
||||||
description: description == null
|
mobileFullscreen: mobileFullscreen,
|
||||||
? null
|
contentPadding: contentPadding,
|
||||||
: Text(description, style: theme.textTheme.muted),
|
scrollable: scrollable,
|
||||||
footer: Row(
|
showCloseButton: showCloseButton,
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
onClose: onClose,
|
||||||
children: actions ?? <Widget>[
|
onSubmit: onSubmit,
|
||||||
ShadButton.ghost(
|
enableFocusTrap: enableFocusTrap,
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
child: body,
|
||||||
child: const Text('닫기'),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: body,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
@@ -5,54 +7,107 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
|||||||
class SuperportTable extends StatelessWidget {
|
class SuperportTable extends StatelessWidget {
|
||||||
const SuperportTable({
|
const SuperportTable({
|
||||||
super.key,
|
super.key,
|
||||||
required this.columns,
|
required List<Widget> columns,
|
||||||
required this.rows,
|
required List<List<Widget>> rows,
|
||||||
this.columnSpanExtent,
|
this.columnSpanExtent,
|
||||||
this.rowHeight = 56,
|
this.rowHeight = 56,
|
||||||
|
this.maxHeight,
|
||||||
this.onRowTap,
|
this.onRowTap,
|
||||||
this.emptyLabel = '데이터가 없습니다.',
|
this.emptyLabel = '데이터가 없습니다.',
|
||||||
});
|
}) : _columns = columns,
|
||||||
|
_rows = rows,
|
||||||
|
_headerCells = null,
|
||||||
|
_rowCells = null;
|
||||||
|
|
||||||
final List<Widget> columns;
|
const SuperportTable.fromCells({
|
||||||
final List<List<Widget>> rows;
|
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 TableSpanExtent? Function(int index)? columnSpanExtent;
|
||||||
final double rowHeight;
|
final double rowHeight;
|
||||||
|
final double? maxHeight;
|
||||||
final void Function(int index)? onRowTap;
|
final void Function(int index)? onRowTap;
|
||||||
final String emptyLabel;
|
final String emptyLabel;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (rows.isEmpty) {
|
late final List<ShadTableCell> headerCells;
|
||||||
final theme = ShadTheme.of(context);
|
late final List<List<ShadTableCell>> tableRows;
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(32),
|
|
||||||
child: Center(
|
|
||||||
child: Text(emptyLabel, style: theme.textTheme.muted),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final tableRows = [
|
if (_rowCells case final rows?) {
|
||||||
for (final row in rows)
|
if (rows.isEmpty) {
|
||||||
row
|
final theme = ShadTheme.of(context);
|
||||||
.map(
|
return Padding(
|
||||||
(cell) => cell is ShadTableCell ? cell : ShadTableCell(child: cell),
|
padding: const EdgeInsets.all(32),
|
||||||
)
|
child: Center(child: Text(emptyLabel, style: theme.textTheme.muted)),
|
||||||
.toList(),
|
);
|
||||||
];
|
}
|
||||||
|
final header = _headerCells;
|
||||||
return ShadTable.list(
|
if (header == null) {
|
||||||
header: columns
|
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(
|
.map(
|
||||||
(cell) => cell is ShadTableCell
|
(cell) => cell is ShadTableCell
|
||||||
? cell
|
? cell
|
||||||
: ShadTableCell.header(child: cell),
|
: ShadTableCell.header(child: cell),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList();
|
||||||
columnSpanExtent: columnSpanExtent,
|
tableRows = [
|
||||||
rowSpanExtent: (_) => FixedTableSpanExtent(rowHeight),
|
for (final row in rows)
|
||||||
onRowTap: onRowTap,
|
row
|
||||||
children: tableRows,
|
.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 "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
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
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
flutter_secure_storage_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
@@ -5,8 +5,10 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import flutter_secure_storage_macos
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
60
pubspec.lock
60
pubspec.lock
@@ -155,6 +155,54 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_shaders:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -233,10 +281,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: js
|
name: js
|
||||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.2"
|
version: "0.6.7"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -554,6 +602,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
|
win32:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32
|
||||||
|
sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.14.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ dependencies:
|
|||||||
dio: ^5.5.0+1
|
dio: ^5.5.0+1
|
||||||
get_it: ^7.7.0
|
get_it: ^7.7.0
|
||||||
flutter_dotenv: ^5.1.0
|
flutter_dotenv: ^5.1.0
|
||||||
|
flutter_secure_storage: ^9.2.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
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();
|
repository = _MockApprovalRepository();
|
||||||
GetIt.I.registerLazySingleton<ApprovalRepository>(() => repository);
|
GetIt.I.registerLazySingleton<ApprovalRepository>(() => repository);
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,48 @@ void main() {
|
|||||||
expect(find.text('고객사명을 입력하세요.'), findsOneWidget);
|
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 {
|
testWidgets('신규 등록 성공 시 repository.create 호출', (tester) async {
|
||||||
var listCallCount = 0;
|
var listCallCount = 0;
|
||||||
when(
|
when(
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ void main() {
|
|||||||
await tester.pumpWidget(_buildApp(const GroupPermissionPage()));
|
await tester.pumpWidget(_buildApp(const GroupPermissionPage()));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('대시보드'), findsOneWidget);
|
expect(find.text('대시보드'), findsWidgets);
|
||||||
expect(find.text('관리자'), findsOneWidget);
|
expect(find.text('관리자'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ void main() {
|
|||||||
expect(capturedInput?.canCreate, isTrue);
|
expect(capturedInput?.canCreate, isTrue);
|
||||||
expect(capturedInput?.canUpdate, isTrue);
|
expect(capturedInput?.canUpdate, isTrue);
|
||||||
expect(find.byType(Dialog), findsNothing);
|
expect(find.byType(Dialog), findsNothing);
|
||||||
expect(find.text('대시보드'), findsOneWidget);
|
expect(find.text('대시보드'), findsWidgets);
|
||||||
verify(() => permissionRepository.create(any())).called(1);
|
verify(() => permissionRepository.create(any())).called(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ void main() {
|
|||||||
testWidgets('플래그 Off 시 스펙 문서 화면을 노출한다', (tester) async {
|
testWidgets('플래그 Off 시 스펙 문서 화면을 노출한다', (tester) async {
|
||||||
dotenv.testLoad(fileInput: 'FEATURE_PRODUCTS_ENABLED=false\n');
|
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();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.text('장비 모델(제품) 관리'), findsOneWidget);
|
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();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('P-001'), findsOneWidget);
|
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.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('신규 등록'));
|
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.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('신규 등록'));
|
await tester.tap(find.text('신규 등록'));
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ void main() {
|
|||||||
group('플래그 On', () {
|
group('플래그 On', () {
|
||||||
late _MockUserRepository userRepository;
|
late _MockUserRepository userRepository;
|
||||||
late _MockGroupRepository groupRepository;
|
late _MockGroupRepository groupRepository;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
dotenv.testLoad(fileInput: 'FEATURE_USERS_ENABLED=true\n');
|
dotenv.testLoad(fileInput: 'FEATURE_USERS_ENABLED=true\n');
|
||||||
userRepository = _MockUserRepository();
|
userRepository = _MockUserRepository();
|
||||||
@@ -153,6 +152,14 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('신규 등록 성공', (tester) async {
|
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;
|
var listCallCount = 0;
|
||||||
when(
|
when(
|
||||||
() => userRepository.list(
|
() => userRepository.list(
|
||||||
@@ -214,9 +221,21 @@ void main() {
|
|||||||
await tester.enterText(editableTexts.at(0), 'A010');
|
await tester.enterText(editableTexts.at(0), 'A010');
|
||||||
await tester.enterText(editableTexts.at(1), '신규 사용자');
|
await tester.enterText(editableTexts.at(1), '신규 사용자');
|
||||||
|
|
||||||
await tester.tap(find.text('그룹을 선택하세요'));
|
final selectFinder = find.descendant(
|
||||||
await tester.pumpAndSettle();
|
of: dialog,
|
||||||
await tester.tap(find.text('관리자'));
|
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.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('등록'));
|
await tester.tap(find.text('등록'));
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ void main() {
|
|||||||
testWidgets('FEATURE_VENDORS_ENABLED=false 이면 스펙 페이지를 노출한다', (tester) async {
|
testWidgets('FEATURE_VENDORS_ENABLED=false 이면 스펙 페이지를 노출한다', (tester) async {
|
||||||
dotenv.testLoad(fileInput: 'FEATURE_VENDORS_ENABLED=false\n');
|
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();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.text('제조사(벤더) 관리'), findsOneWidget);
|
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();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('V-001'), findsOneWidget);
|
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.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('신규 등록'));
|
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.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('신규 등록'));
|
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/entities/warehouse.dart';
|
||||||
import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.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/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 _MockWarehouseRepository extends Mock implements WarehouseRepository {}
|
||||||
|
|
||||||
class _FakeWarehouseInput extends Fake implements WarehouseInput {}
|
class _FakeWarehouseInput extends Fake implements WarehouseInput {}
|
||||||
|
|
||||||
|
class _MockPostalSearchRepository extends Mock
|
||||||
|
implements PostalSearchRepository {}
|
||||||
|
|
||||||
Widget _buildApp(Widget child) {
|
Widget _buildApp(Widget child) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
home: ShadTheme(
|
home: ShadTheme(
|
||||||
@@ -41,7 +46,9 @@ void main() {
|
|||||||
testWidgets('플래그 Off 시 스펙 화면', (tester) async {
|
testWidgets('플래그 Off 시 스펙 화면', (tester) async {
|
||||||
dotenv.testLoad(fileInput: 'FEATURE_WAREHOUSES_ENABLED=false\n');
|
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();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.text('입고지(창고) 관리'), findsOneWidget);
|
expect(find.text('입고지(창고) 관리'), findsOneWidget);
|
||||||
@@ -50,11 +57,32 @@ void main() {
|
|||||||
|
|
||||||
group('플래그 On', () {
|
group('플래그 On', () {
|
||||||
late _MockWarehouseRepository repository;
|
late _MockWarehouseRepository repository;
|
||||||
|
late _MockPostalSearchRepository postalRepository;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
dotenv.testLoad(fileInput: 'FEATURE_WAREHOUSES_ENABLED=true\n');
|
dotenv.testLoad(fileInput: 'FEATURE_WAREHOUSES_ENABLED=true\n');
|
||||||
repository = _MockWarehouseRepository();
|
repository = _MockWarehouseRepository();
|
||||||
|
postalRepository = _MockPostalSearchRepository();
|
||||||
GetIt.I.registerLazySingleton<WarehouseRepository>(() => repository);
|
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 {
|
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();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('WH-001'), findsOneWidget);
|
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.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('신규 등록'));
|
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.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('신규 등록'));
|
await tester.tap(find.text('신규 등록'));
|
||||||
@@ -177,14 +211,31 @@ void main() {
|
|||||||
|
|
||||||
await tester.enterText(fields.at(0), 'WH-100');
|
await tester.enterText(fields.at(0), 'WH-100');
|
||||||
await tester.enterText(fields.at(1), '신규 창고');
|
await tester.enterText(fields.at(1), '신규 창고');
|
||||||
await tester.enterText(fields.at(2), '12345');
|
await tester.enterText(fields.at(2), '06000');
|
||||||
await tester.enterText(fields.at(3), '주소');
|
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.tap(find.text('등록'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(capturedInput, isNotNull);
|
expect(capturedInput, isNotNull);
|
||||||
expect(capturedInput?.warehouseCode, 'WH-100');
|
expect(capturedInput?.warehouseCode, 'WH-100');
|
||||||
|
expect(capturedInput?.zipcode, '06000');
|
||||||
expect(find.byType(Dialog), findsNothing);
|
expect(find.byType(Dialog), findsNothing);
|
||||||
expect(find.text('WH-100'), findsOneWidget);
|
expect(find.text('WH-100'), findsOneWidget);
|
||||||
verify(() => repository.create(any())).called(1);
|
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 "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
flutter_secure_storage_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
Reference in New Issue
Block a user