전역 구조 리팩터링 및 테스트 확장

This commit is contained in:
JiWoong Sul
2025-09-29 01:51:47 +09:00
parent c00c0c9ab2
commit fef7108479
70 changed files with 7709 additions and 3185 deletions

105
doc/input_widget_guide.md Normal file
View 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` — 입고 등록 모달
위 가이드를 준수하면 폼 간 스타일과 상호작용 규칙을 동일하게 유지할 수 있다.

View File

@@ -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());
}
} }

View File

@@ -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 다운로드 기능입니다.',
), ),
], ],

View File

@@ -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);
}
} }
} }

View 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;
}
}

View File

@@ -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,
) async {
if (!_shouldAttemptRefresh(err)) {
handler.next(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();
}

View 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;
}

View File

@@ -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',

View 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();

View 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);
}
}

View 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();
}

View 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;
}
}
}

View 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),
);
}
}

View 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!;
}
}

View File

@@ -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(),
};
} }
} }

View File

@@ -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:

View File

@@ -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,42 +529,25 @@ 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>(
context: context,
builder: (dialogContext) {
final step = detail.step; final step = detail.step;
final theme = ShadTheme.of(dialogContext); await SuperportDialog.show<void>(
return Dialog( context: context,
insetPadding: const EdgeInsets.all(24), dialog: SuperportDialog(
clipBehavior: Clip.antiAlias, title: '결재 단계 상세',
child: ShadCard( description: '결재번호 ${detail.approvalNo}',
title: Text('결재 단계 상세', style: theme.textTheme.h3), constraints: const BoxConstraints(maxWidth: 560),
description: Text( contentPadding: const EdgeInsets.symmetric(
'결재번호 ${detail.approvalNo}', horizontal: 20,
style: theme.textTheme.muted, vertical: 18,
), ),
footer: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ShadButton.ghost(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('닫기'),
),
],
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
_DetailRow(label: '단계 순서', value: '${step.stepOrder}'), _DetailRow(label: '단계 순서', value: '${step.stepOrder}'),
_DetailRow(label: '승인자', value: step.approver.name), _DetailRow(label: '승인자', value: step.approver.name),
_DetailRow(label: '상태', value: step.status.name), _DetailRow(label: '상태', value: step.status.name),
_DetailRow( _DetailRow(label: '배정일시', value: _formatDate(step.assignedAt)),
label: '배정일시',
value: _formatDate(step.assignedAt),
),
_DetailRow( _DetailRow(
label: '결정일시', label: '결정일시',
value: step.decidedAt == null value: step.decidedAt == null
@@ -571,16 +555,13 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
: _formatDate(step.decidedAt!), : _formatDate(step.decidedAt!),
), ),
_DetailRow(label: '템플릿', value: detail.templateName ?? '-'), _DetailRow(label: '템플릿', value: detail.templateName ?? '-'),
_DetailRow( _DetailRow(label: '트랜잭션번호', value: detail.transactionNo ?? '-'),
label: '트랜잭션번호',
value: detail.transactionNo ?? '-',
),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'비고', '비고',
style: theme.textTheme.small.copyWith( style: ShadTheme.of(
fontWeight: FontWeight.w600, context,
), ).textTheme.small.copyWith(fontWeight: FontWeight.w600),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
ShadTextarea( ShadTextarea(
@@ -592,9 +573,6 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
], ],
), ),
), ),
),
);
},
); );
} }
@@ -724,28 +702,20 @@ class _StepFormDialogState extends State<_StepFormDialog> {
final theme = ShadTheme.of(context); final 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),
footer: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ShadButton.ghost(
onPressed: () => Navigator.of(context).pop(),
child: const Text('취소'),
),
const SizedBox(width: 12),
ShadButton(
key: const ValueKey('step_form_submit'), key: const ValueKey('step_form_submit'),
onPressed: _handleSubmit, onPressed: _handleSubmit,
child: Text(widget.submitLabel), 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: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -821,7 +791,6 @@ class _StepFormDialogState extends State<_StepFormDialog> {
], ],
), ),
), ),
),
); );
} }

View File

@@ -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,15 +216,79 @@ 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('변경일시')),
ShadTableCell.header(child: Text('동작')),
],
rows: templates.map((template) {
return [
ShadTableCell(child: Text('${template.id}')),
ShadTableCell(child: Text(template.code)),
ShadTableCell(child: Text(template.name)),
ShadTableCell(
child: Text(
template.description?.isNotEmpty == true
? template.description!
: '-',
),
),
ShadTableCell(
child: template.isActive
? const ShadBadge(child: Text('사용'))
: const ShadBadge.outline(child: Text('미사용')),
),
ShadTableCell(
child: Text(
template.updatedAt == null
? '-'
: _dateFormat.format(
template.updatedAt!.toLocal(),
),
),
),
ShadTableCell(
alignment: Alignment.centerRight,
child: Wrap(
spacing: 8,
children: [
ShadButton.ghost(
key: ValueKey(
'template_edit_${template.id}',
),
size: ShadButtonSize.sm,
onPressed: _controller.isSubmitting
? null
: () => _openEditTemplate(template),
child: const Text('수정'),
),
template.isActive
? ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: _controller.isSubmitting
? null
: () => _confirmDelete(template),
child: const Text('삭제'),
) )
.toList(), : ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: _controller.isSubmitting
? null
: () => _confirmRestore(template),
child: const Text('복구'),
),
],
),
),
];
}).toList(),
rowHeight: 56,
maxHeight: 480,
columnSpanExtent: (index) { columnSpanExtent: (index) {
switch (index) { switch (index) {
case 2: case 2:
@@ -238,72 +305,6 @@ class _ApprovalTemplateEnabledPageState
return const FixedTableSpanExtent(140); return const FixedTableSpanExtent(140);
} }
}, },
children: templates.map((template) {
return [
ShadTableCell(child: Text('${template.id}')),
ShadTableCell(child: Text(template.code)),
ShadTableCell(child: Text(template.name)),
ShadTableCell(
child: Text(
template.description?.isNotEmpty == true
? template.description!
: '-',
),
),
ShadTableCell(
child: template.isActive
? const ShadBadge(child: Text('사용'))
: const ShadBadge.outline(
child: Text('미사용'),
),
),
ShadTableCell(
child: Text(
template.updatedAt == null
? '-'
: _dateFormat.format(
template.updatedAt!.toLocal(),
),
),
),
ShadTableCell(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ShadButton.ghost(
key: ValueKey(
'template_edit_${template.id}',
),
size: ShadButtonSize.sm,
onPressed: _controller.isSubmitting
? null
: () => _openEditTemplate(template),
child: const Text('수정'),
),
const SizedBox(width: 8),
template.isActive
? ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: _controller.isSubmitting
? null
: () =>
_confirmDelete(template),
child: const Text('삭제'),
)
: ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: _controller.isSubmitting
? null
: () =>
_confirmRestore(template),
child: const Text('복구'),
),
],
),
),
];
}).toList(),
),
), ),
const SizedBox(height: 16), 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: [ actions: [
TextButton( ShadButton.ghost(
onPressed: () => Navigator.of(dialogContext).pop(false), onPressed: () => Navigator.of(context).pop(false),
child: const Text('취소'), child: const Text('취소'),
), ),
FilledButton.tonal( ShadButton(
onPressed: () => Navigator.of(dialogContext).pop(true), onPressed: () => Navigator.of(context).pop(true),
child: const Text('삭제'), 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: [
TextButton( ShadButton.ghost(
onPressed: () => Navigator.of(dialogContext).pop(false), onPressed: () => Navigator.of(context).pop(false),
child: const Text('취소'), child: const Text('취소'),
), ),
FilledButton( ShadButton(
onPressed: () => Navigator.of(dialogContext).pop(true), onPressed: () => Navigator.of(context).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 ? '수정 완료' : '생성 완료'),
), ),
], ],

View File

@@ -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),
],
),
),
], ],
); );
} }

View 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),
);
}

View File

@@ -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,
),
),
],
),
),
);
},
),
),
),
);
},
);
},
);
}
}

View File

@@ -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;
}

View File

@@ -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));
}

View File

@@ -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('로그인'),
],
),
), ),
], ],
), ),

View File

@@ -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,14 +518,41 @@ 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 selection = selectedPostalNotifier.value;
if (text.isEmpty) {
if (selection != null) {
selectedPostalNotifier.value = null;
}
zipcodeError.value = null;
return;
}
if (selection != null && selection.zipcode != text) {
selectedPostalNotifier.value = null;
}
if (zipcodeError.value != null) {
zipcodeError.value = null;
}
}
void handlePostalSelectionChange() {
if (selectedPostalNotifier.value != null) {
zipcodeError.value = null;
}
}
zipcodeController.addListener(handleZipcodeChange);
selectedPostalNotifier.addListener(handlePostalSelectionChange);
Future<void> openPostalSearch(BuildContext dialogContext) async {
final keyword = zipcodeController.text.trim(); final keyword = zipcodeController.text.trim();
final result = await showPostalSearchDialog( final result = await showPostalSearchDialog(
dialogContext, dialogContext,
@@ -531,11 +561,11 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
if (result == null) { if (result == null) {
return; return;
} }
isApplyingPostalSelection = true;
zipcodeController zipcodeController
..text = result.zipcode ..text = result.zipcode
..selection = TextSelection.collapsed( ..selection = TextSelection.collapsed(offset: result.zipcode.length);
offset: result.zipcode.length, isApplyingPostalSelection = false;
);
selectedPostalNotifier.value = result; selectedPostalNotifier.value = result;
if (result.fullAddress.isNotEmpty) { if (result.fullAddress.isNotEmpty) {
addressController addressController
@@ -546,32 +576,15 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
} }
} }
return Dialog( await SuperportDialog.show<bool>(
insetPadding: const EdgeInsets.all(24), context: parentContext,
clipBehavior: Clip.antiAlias, dialog: SuperportDialog(
child: ConstrainedBox( title: isEdit ? '고객사 수정' : '고객사 등록',
constraints: const BoxConstraints(maxWidth: 560), description: '고객사 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.',
child: ShadCard( primaryAction: ValueListenableBuilder<bool>(
title: Text(
isEdit ? '고객사 수정' : '고객사 등록',
style: theme.textTheme.h3,
),
description: Text(
'고객사 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.',
style: theme.textTheme.muted,
),
footer: ValueListenableBuilder<bool>(
valueListenable: saving, valueListenable: saving,
builder: (_, isSaving, __) { builder: (context, isSaving, _) {
return Row( return ShadButton(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ShadButton.ghost(
onPressed: isSaving ? null : () => navigator.pop(false),
child: const Text('취소'),
),
const SizedBox(width: 12),
ShadButton(
onPressed: isSaving onPressed: isSaving
? null ? null
: () async { : () async {
@@ -584,12 +597,13 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
final note = noteController.text.trim(); final note = noteController.text.trim();
final partner = partnerNotifier.value; final partner = partnerNotifier.value;
var general = generalNotifier.value; var general = generalNotifier.value;
final selectedPostal = selectedPostalNotifier.value;
codeError.value = code.isEmpty codeError.value = code.isEmpty ? '고객사코드를 입력하세요.' : null;
? '고객사코드를 입력하세요.' nameError.value = name.isEmpty ? '고객사명을 입력하세요.' : null;
: null; zipcodeError.value =
nameError.value = name.isEmpty zipcode.isNotEmpty && selectedPostal == null
? '고객사명을 입력하세요.' ? '우편번호 검색으로 주소를 선택하세요.'
: null; : null;
if (!partner && !general) { if (!partner && !general) {
@@ -603,6 +617,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
if (codeError.value != null || if (codeError.value != null ||
nameError.value != null || nameError.value != null ||
zipcodeError.value != null ||
typeError.value != null) { typeError.value != null) {
return; return;
} }
@@ -616,17 +631,13 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
email: email.isEmpty ? null : email, email: email.isEmpty ? null : email,
mobileNo: mobile.isEmpty ? null : mobile, mobileNo: mobile.isEmpty ? null : mobile,
zipcode: zipcode.isEmpty ? null : zipcode, zipcode: zipcode.isEmpty ? null : zipcode,
addressDetail: address.isEmpty addressDetail: address.isEmpty ? null : address,
? null
: address,
isActive: isActiveNotifier.value, isActive: isActiveNotifier.value,
note: note.isEmpty ? null : note, note: note.isEmpty ? null : note,
); );
final navigator = Navigator.of(context);
final response = isEdit final response = isEdit
? await _controller.update( ? await _controller.update(customerId!, input)
customerId!,
input,
)
: await _controller.create(input); : await _controller.create(input);
saving.value = false; saving.value = false;
if (response != null) { if (response != null) {
@@ -634,20 +645,33 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
return; return;
} }
if (mounted) { if (mounted) {
_showSnack( _showSnack(isEdit ? '고객사를 수정했습니다.' : '고객사를 등록했습니다.');
isEdit ? '고객사를 수정했습니다.' : '고객사를 등록했습니다.',
);
} }
navigator.pop(true); navigator.pop(true);
} }
}, },
child: Text(isEdit ? '저장' : '등록'), child: Text(isEdit ? '저장' : '등록'),
),
],
); );
}, },
), ),
child: SingleChildScrollView( secondaryAction: ValueListenableBuilder<bool>(
valueListenable: saving,
builder: (context, isSaving, _) {
return ShadButton.ghost(
onPressed: isSaving
? null
: () => Navigator.of(context).pop(false),
child: const Text('취소'),
);
},
),
child: ValueListenableBuilder<bool>(
valueListenable: saving,
builder: (context, isSaving, _) {
final theme = ShadTheme.of(context);
final materialTheme = Theme.of(context);
return SingleChildScrollView(
padding: const EdgeInsets.only(right: 12), padding: const EdgeInsets.only(right: 12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -726,7 +750,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
return ValueListenableBuilder<String?>( return ValueListenableBuilder<String?>(
valueListenable: typeError, valueListenable: typeError,
builder: (_, errorText, __) { builder: (_, errorText, __) {
final onChanged = saving.value final onChanged = isSaving
? null ? null
: (bool? value) { : (bool? value) {
if (value == null) return; if (value == null) return;
@@ -738,7 +762,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
typeError.value = null; typeError.value = null;
} }
}; };
final onChangedGeneral = saving.value final onChangedGeneral = isSaving
? null ? null
: (bool? value) { : (bool? value) {
if (value == null) return; if (value == null) return;
@@ -753,8 +777,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
return _FormField( return _FormField(
label: '유형', label: '유형',
child: Column( child: Column(
crossAxisAlignment: crossAxisAlignment: CrossAxisAlignment.start,
CrossAxisAlignment.start,
children: [ children: [
Row( Row(
children: [ children: [
@@ -775,16 +798,12 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
), ),
if (errorText != null) if (errorText != null)
Padding( Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(top: 6),
top: 6,
),
child: Text( child: Text(
errorText, errorText,
style: theme.textTheme.small style: theme.textTheme.small.copyWith(
.copyWith( color:
color: materialTheme materialTheme.colorScheme.error,
.colorScheme
.error,
), ),
), ),
), ),
@@ -814,7 +833,10 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_FormField( ValueListenableBuilder<String?>(
valueListenable: zipcodeError,
builder: (_, zipcodeErrorText, __) {
return _FormField(
label: '우편번호', label: '우편번호',
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -830,9 +852,9 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
ShadButton.outline( ShadButton.outline(
onPressed: saving.value onPressed: isSaving
? null ? null
: openPostalSearch, : () => openPostalSearch(context),
child: const Text('검색'), child: const Text('검색'),
), ),
], ],
@@ -858,8 +880,20 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
); );
}, },
), ),
if (zipcodeErrorText != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
zipcodeErrorText,
style: theme.textTheme.small.copyWith(
color: materialTheme.colorScheme.error,
),
),
),
], ],
), ),
);
},
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_FormField( _FormField(
@@ -879,7 +913,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
children: [ children: [
ShadSwitch( ShadSwitch(
value: value, value: value,
onChanged: saving.value onChanged: isSaving
? null ? null
: (next) => isActiveNotifier.value = next, : (next) => isActiveNotifier.value = next,
), ),
@@ -898,12 +932,13 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
if (existing != null) ..._buildAuditInfo(existing, theme), 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: [
TextButton( ShadButton.ghost(
onPressed: () => Navigator.of(dialogContext).pop(false), onPressed: () => Navigator.of(context).pop(false),
child: const Text('취소'), child: const Text('취소'),
), ),
TextButton( ShadButton(
onPressed: () => Navigator.of(dialogContext).pop(true), onPressed: () => Navigator.of(context).pop(true),
child: const Text('삭제'), child: const Text('삭제'),
), ),
], ],
); ),
},
); );
if (confirmed == true && customer.id != null) { if (confirmed == true && customer.id != null) {

View File

@@ -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(
@@ -285,12 +288,8 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> {
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,49 +351,36 @@ 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);
return Dialog(
insetPadding: const EdgeInsets.all(24),
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 540), constraints: const BoxConstraints(maxWidth: 540),
child: ShadCard( actions: [
title: Text( ValueListenableBuilder<bool>(
isEdit ? '그룹 수정' : '그룹 등록',
style: theme.textTheme.h3,
),
description: Text(
'그룹 정보를 ${isEdit ? '수정' : '입력'}하세요.',
style: theme.textTheme.muted,
),
footer: ValueListenableBuilder<bool>(
valueListenable: saving, valueListenable: saving,
builder: (_, isSaving, __) { builder: (dialogContext, isSaving, __) {
return Row( return ShadButton.ghost(
mainAxisAlignment: MainAxisAlignment.end, onPressed: isSaving
children: [ ? null
ShadButton.ghost( : () => Navigator.of(dialogContext).pop(),
onPressed: isSaving ? null : () => navigator.pop(false),
child: const Text('취소'), child: const Text('취소'),
);
},
), ),
const SizedBox(width: 12), ValueListenableBuilder<bool>(
ShadButton( valueListenable: saving,
builder: (dialogContext, isSaving, __) {
return ShadButton(
onPressed: isSaving onPressed: isSaving
? null ? null
: () async { : () async {
final name = nameController.text.trim(); final name = nameController.text.trim();
final description = descriptionController.text final description = descriptionController.text.trim();
.trim();
final note = noteController.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;
@@ -403,13 +389,12 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> {
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
: description,
isDefault: isDefaultNotifier.value, isDefault: isDefaultNotifier.value,
isActive: isActiveNotifier.value, isActive: isActiveNotifier.value,
note: note.isEmpty ? null : note, 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);
@@ -419,20 +404,22 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> {
return; return;
} }
if (mounted) { if (mounted) {
_showSnack( _showSnack(isEdit ? '그룹을 수정했습니다.' : '그룹을 등록했습니다.');
isEdit ? '그룹을 수정했습니다.' : '그룹을 등록했습니다.',
);
} }
navigator.pop(true); navigator.pop();
} }
}, },
child: Text(isEdit ? '저장' : '등록'), child: Text(isEdit ? '저장' : '등록'),
),
],
); );
}, },
), ),
child: SizedBox( ],
child: StatefulBuilder(
builder: (dialogContext, _) {
final theme = ShadTheme.of(dialogContext);
final materialTheme = Theme.of(dialogContext);
return SizedBox(
width: double.infinity, width: double.infinity,
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.only(right: 12), padding: const EdgeInsets.only(right: 12),
@@ -489,8 +476,7 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> {
value: value, value: value,
onChanged: saving.value onChanged: saving.value
? null ? null
: (next) => : (next) => isDefaultNotifier.value = next,
isDefaultNotifier.value = next,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(value ? '기본 그룹' : '일반 그룹'), Text(value ? '기본 그룹' : '일반 그룹'),
@@ -525,7 +511,7 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> {
label: '비고', label: '비고',
child: ShadTextarea(controller: noteController), child: ShadTextarea(controller: noteController),
), ),
if (isEdit) ...[ if (existingGroup != null) ...[
const SizedBox(height: 20), const SizedBox(height: 20),
Text( Text(
'생성일시: ${_formatDateTime(existingGroup.createdAt)}', '생성일시: ${_formatDateTime(existingGroup.createdAt)}',
@@ -540,11 +526,10 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> {
], ],
), ),
), ),
),
),
),
); );
}, },
),
),
); );
nameController.dispose(); nameController.dispose();

View File

@@ -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(
@@ -396,11 +386,11 @@ class _GroupPermissionEnabledPageState
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,74 +420,72 @@ 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);
return Dialog(
insetPadding: const EdgeInsets.all(24),
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600), constraints: const BoxConstraints(maxWidth: 600),
child: ShadCard( secondaryAction: ValueListenableBuilder<bool>(
title: Text(
isEdit ? '권한 수정' : '권한 등록',
style: theme.textTheme.h3,
),
description: Text(
'그룹과 메뉴의 권한을 ${isEdit ? '수정' : '등록'}하세요.',
style: theme.textTheme.muted,
),
footer: ValueListenableBuilder<bool>(
valueListenable: saving, valueListenable: saving,
builder: (_, isSaving, __) { builder: (dialogContext, isSaving, __) {
return Row( return ShadButton.ghost(
mainAxisAlignment: MainAxisAlignment.end, onPressed: isSaving
children: [ ? null
ShadButton.ghost( : () => Navigator.of(dialogContext).pop(false),
onPressed: isSaving ? null : () => navigator.pop(false),
child: const Text('취소'), child: const Text('취소'),
);
},
), ),
const SizedBox(width: 12), primaryAction: ValueListenableBuilder<bool>(
ShadButton( valueListenable: saving,
builder: (dialogContext, isSaving, __) {
return ShadButton(
onPressed: isSaving onPressed: isSaving
? null ? null
: () async { : () async {
final groupId = groupNotifier.value; final groupId = groupNotifier.value;
final menuId = menuNotifier.value; final menuId = menuNotifier.value;
groupError.value = groupId == null groupError.value = groupId == null ? '그룹을 선택하세요.' : null;
? '그룹을 선택하세요.' menuError.value = menuId == null ? '메뉴를 선택하세요.' : null;
: null; if (groupError.value != null || menuError.value != null) {
menuError.value = menuId == null
? '메뉴를 선택하세요.'
: null;
if (groupError.value != null ||
menuError.value != null) {
return; return;
} }
saving.value = true; saving.value = true;
final trimmedNote = noteController.text.trim();
final input = GroupPermissionInput( final input = GroupPermissionInput(
groupId: groupId!, groupId: groupId!,
menuId: menuId!, menuId: menuId!,
@@ -506,15 +494,11 @@ class _GroupPermissionEnabledPageState
canUpdate: updateNotifier.value, canUpdate: updateNotifier.value,
canDelete: deleteNotifier.value, canDelete: deleteNotifier.value,
isActive: activeNotifier.value, isActive: activeNotifier.value,
note: noteController.text.trim().isEmpty note: trimmedNote.isEmpty ? null : trimmedNote,
? null
: noteController.text.trim(),
); );
final navigator = Navigator.of(dialogContext);
final response = isEdit final response = isEdit
? await _controller.update( ? await _controller.update(permissionId!, input)
permissionId!,
input,
)
: await _controller.create(input); : await _controller.create(input);
saving.value = false; saving.value = false;
if (response != null) { if (response != null) {
@@ -522,22 +506,21 @@ class _GroupPermissionEnabledPageState
return; return;
} }
if (mounted) { if (mounted) {
_showSnack( _showSnack(isEdit ? '권한을 수정했습니다.' : '권한을 등록했습니다.');
isEdit ? '권한을 수정했습니다.' : '권한을 등록했습니다.',
);
} }
navigator.pop(true); navigator.pop(true);
} }
}, },
child: Text(isEdit ? '저장' : '등록'), child: Text(isEdit ? '저장' : '등록'),
),
],
); );
}, },
), ),
child: SizedBox( child: ValueListenableBuilder<bool>(
width: double.infinity, valueListenable: saving,
child: SingleChildScrollView( builder: (dialogContext, isSaving, __) {
final theme = ShadTheme.of(dialogContext);
final materialTheme = Theme.of(dialogContext);
return SingleChildScrollView(
padding: const EdgeInsets.only(right: 12), padding: const EdgeInsets.only(right: 12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -570,7 +553,7 @@ class _GroupPermissionEnabledPageState
: group.groupName, : group.groupName,
); );
}, },
onChanged: saving.value || isEdit onChanged: isSaving || isEdit
? null ? null
: (value) { : (value) {
groupNotifier.value = value; groupNotifier.value = value;
@@ -633,7 +616,7 @@ class _GroupPermissionEnabledPageState
: menu.menuName, : menu.menuName,
); );
}, },
onChanged: saving.value || isEdit onChanged: isSaving || isEdit
? null ? null
: (value) { : (value) {
menuNotifier.value = value; menuNotifier.value = value;
@@ -669,25 +652,25 @@ class _GroupPermissionEnabledPageState
_PermissionToggleRow( _PermissionToggleRow(
label: '생성권한', label: '생성권한',
notifier: createNotifier, notifier: createNotifier,
enabled: !saving.value, enabled: !isSaving,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_PermissionToggleRow( _PermissionToggleRow(
label: '조회권한', label: '조회권한',
notifier: readNotifier, notifier: readNotifier,
enabled: !saving.value, enabled: !isSaving,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_PermissionToggleRow( _PermissionToggleRow(
label: '수정권한', label: '수정권한',
notifier: updateNotifier, notifier: updateNotifier,
enabled: !saving.value, enabled: !isSaving,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_PermissionToggleRow( _PermissionToggleRow(
label: '삭제권한', label: '삭제권한',
notifier: deleteNotifier, notifier: deleteNotifier,
enabled: !saving.value, enabled: !isSaving,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ValueListenableBuilder<bool>( ValueListenableBuilder<bool>(
@@ -699,7 +682,7 @@ class _GroupPermissionEnabledPageState
children: [ children: [
ShadSwitch( ShadSwitch(
value: value, value: value,
onChanged: saving.value onChanged: isSaving
? null ? null
: (next) => activeNotifier.value = next, : (next) => activeNotifier.value = next,
), ),
@@ -715,26 +698,24 @@ class _GroupPermissionEnabledPageState
label: '비고', label: '비고',
child: ShadTextarea(controller: noteController), child: ShadTextarea(controller: noteController),
), ),
if (isEdit) ...[ if (existingPermission != null) ...[
const SizedBox(height: 20), const SizedBox(height: 20),
Text( Text(
'생성일시: ${_formatDateTime(permission.createdAt)}', '생성일시: ${_formatDateTime(existingPermission.createdAt)}',
style: theme.textTheme.small, style: theme.textTheme.small,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'수정일시: ${_formatDateTime(permission.updatedAt)}', '수정일시: ${_formatDateTime(existingPermission.updatedAt)}',
style: theme.textTheme.small, 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(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('삭제'),
),
],
); );
}, },
),
primaryAction: Builder(
builder: (dialogContext) {
return ShadButton.destructive(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('삭제'),
);
},
),
),
); );
if (confirmed == true && permission.id != null) { if (confirmed == true && permission.id != null) {

View File

@@ -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(
@@ -346,14 +343,9 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
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,38 +402,27 @@ 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);
return Dialog(
insetPadding: const EdgeInsets.all(24),
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560), constraints: const BoxConstraints(maxWidth: 560),
child: ShadCard( secondaryAction: ValueListenableBuilder<bool>(
title: Text(
isEdit ? '메뉴 수정' : '메뉴 등록',
style: theme.textTheme.h3,
),
description: Text(
'메뉴 정보를 ${isEdit ? '수정' : '입력'}하세요.',
style: theme.textTheme.muted,
),
footer: ValueListenableBuilder<bool>(
valueListenable: saving, valueListenable: saving,
builder: (_, isSaving, __) { builder: (dialogContext, isSaving, __) {
return Row( return ShadButton.ghost(
mainAxisAlignment: MainAxisAlignment.end, onPressed: isSaving
children: [ ? null
ShadButton.ghost( : () => Navigator.of(dialogContext).pop(false),
onPressed: isSaving ? null : () => navigator.pop(false),
child: const Text('취소'), child: const Text('취소'),
);
},
), ),
const SizedBox(width: 12), primaryAction: ValueListenableBuilder<bool>(
ShadButton( valueListenable: saving,
builder: (dialogContext, isSaving, __) {
return ShadButton(
onPressed: isSaving onPressed: isSaving
? null ? null
: () async { : () async {
@@ -451,21 +432,15 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
final orderText = orderController.text.trim(); final orderText = orderController.text.trim();
final note = noteController.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 { } else {
orderError.value = null; orderError.value = null;
} }
@@ -486,6 +461,7 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
isActive: isActiveNotifier.value, isActive: isActiveNotifier.value,
note: note.isEmpty ? null : note, note: note.isEmpty ? null : note,
); );
final navigator = Navigator.of(dialogContext);
final response = isEdit final response = isEdit
? await _controller.update(menuId!, input) ? await _controller.update(menuId!, input)
: await _controller.create(input); : await _controller.create(input);
@@ -495,22 +471,21 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
return; return;
} }
if (mounted) { if (mounted) {
_showSnack( _showSnack(isEdit ? '메뉴를 수정했습니다.' : '메뉴를 등록했습니다.');
isEdit ? '메뉴를 수정했습니다.' : '메뉴를 등록했습니다.',
);
} }
navigator.pop(true); navigator.pop(true);
} }
}, },
child: Text(isEdit ? '저장' : '등록'), child: Text(isEdit ? '저장' : '등록'),
),
],
); );
}, },
), ),
child: SizedBox( child: ValueListenableBuilder<bool>(
width: double.infinity, valueListenable: saving,
child: SingleChildScrollView( builder: (dialogContext, isSaving, __) {
final theme = ShadTheme.of(dialogContext);
final materialTheme = Theme.of(dialogContext);
return SingleChildScrollView(
padding: const EdgeInsets.only(right: 12), padding: const EdgeInsets.only(right: 12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -606,7 +581,7 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
: target.menuName; : target.menuName;
return Text(label); return Text(label);
}, },
onChanged: saving.value onChanged: isSaving
? null ? null
: (next) => parentNotifier.value = next, : (next) => parentNotifier.value = next,
options: [ options: [
@@ -670,7 +645,7 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
children: [ children: [
ShadSwitch( ShadSwitch(
value: value, value: value,
onChanged: saving.value onChanged: isSaving
? null ? null
: (next) => isActiveNotifier.value = next, : (next) => isActiveNotifier.value = next,
), ),
@@ -686,7 +661,7 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
label: '비고', label: '비고',
child: ShadTextarea(controller: noteController), child: ShadTextarea(controller: noteController),
), ),
if (isEdit) ...[ if (existingMenu != null) ...[
const SizedBox(height: 20), const SizedBox(height: 20),
Text( Text(
'생성일시: ${_formatDateTime(existingMenu.createdAt)}', '생성일시: ${_formatDateTime(existingMenu.createdAt)}',
@@ -700,12 +675,10 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> {
], ],
], ],
), ),
),
),
),
),
); );
}, },
),
),
); );
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,
dialog: SuperportDialog(
title: '메뉴 삭제',
description: '"${menu.menuName}" 메뉴를 삭제하시겠습니까?',
secondaryAction: Builder(
builder: (dialogContext) { builder: (dialogContext) {
return AlertDialog( return ShadButton.ghost(
title: const Text('메뉴 삭제'),
content: Text('"${menu.menuName}" 메뉴를 삭제하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false), onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('취소'), child: const Text('취소'),
),
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('삭제'),
),
],
); );
}, },
),
primaryAction: Builder(
builder: (dialogContext) {
return ShadButton.destructive(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('삭제'),
);
},
),
),
); );
if (confirmed == true && menu.id != null) { if (confirmed == true && menu.id != null) {

View File

@@ -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 {

View File

@@ -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';
@@ -17,7 +20,9 @@ import '../../domain/repositories/product_repository.dart';
import '../controllers/product_controller.dart'; import '../controllers/product_controller.dart';
class ProductPage extends StatelessWidget { class ProductPage extends StatelessWidget {
const ProductPage({super.key}); const ProductPage({super.key, required this.routeUri});
final Uri routeUri;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -65,12 +70,14 @@ class ProductPage extends StatelessWidget {
); );
} }
return const _ProductEnabledPage(); return _ProductEnabledPage(routeUri: routeUri);
} }
} }
class _ProductEnabledPage extends StatefulWidget { class _ProductEnabledPage extends StatefulWidget {
const _ProductEnabledPage(); const _ProductEnabledPage({required this.routeUri});
final Uri routeUri;
@override @override
State<_ProductEnabledPage> createState() => _ProductEnabledPageState(); State<_ProductEnabledPage> createState() => _ProductEnabledPageState();
@@ -83,6 +90,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm'); final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm');
bool _lookupsLoaded = false; bool _lookupsLoaded = false;
String? _lastError; String? _lastError;
String? _lastRouteSignature;
@override @override
void initState() { void initState() {
@@ -92,12 +100,20 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
vendorRepository: GetIt.I<VendorRepository>(), vendorRepository: GetIt.I<VendorRepository>(),
uomRepository: GetIt.I<UomRepository>(), uomRepository: GetIt.I<UomRepository>(),
)..addListener(_handleControllerUpdate); )..addListener(_handleControllerUpdate);
WidgetsBinding.instance.addPostFrameCallback((_) async { }
await Future.wait([_controller.loadLookups(), _controller.fetch()]);
setState(() { @override
_lookupsLoaded = true; void didChangeDependencies() {
}); super.didChangeDependencies();
}); _maybeApplyRoute();
}
@override
void didUpdateWidget(covariant _ProductEnabledPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.routeUri != oldWidget.routeUri) {
_maybeApplyRoute();
}
} }
void _handleControllerUpdate() { void _handleControllerUpdate() {
@@ -138,7 +154,8 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
? 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.vendorFilter != null || _controller.vendorFilter != null ||
_controller.uomFilter != null || _controller.uomFilter != null ||
_controller.statusFilter != ProductStatusFilter.all; _controller.statusFilter != ProductStatusFilter.all;
@@ -161,6 +178,29 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
), ),
], ],
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();
_updateRoute(
page: 1,
queryOverride: '',
vendorOverride: null,
uomOverride: null,
statusOverride: ProductStatusFilter.all,
);
},
child: const Text('초기화'),
),
],
children: [ children: [
SizedBox( SizedBox(
width: 260, width: 260,
@@ -184,16 +224,15 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
} }
final vendor = _controller.vendorOptions.firstWhere( final vendor = _controller.vendorOptions.firstWhere(
(v) => v.id == value, (v) => v.id == value,
orElse: () => Vendor(id: value, vendorCode: '', vendorName: ''), orElse: () =>
Vendor(id: value, vendorCode: '', vendorName: ''),
); );
return Text(vendor.vendorName); return Text(vendor.vendorName);
}, },
onChanged: (value) => _controller.updateVendorFilter(value), onChanged: (value) =>
_updateRoute(page: 1, vendorOverride: value),
options: [ options: [
const ShadOption<int?>( const ShadOption<int?>(value: null, child: Text('제조사 전체')),
value: null,
child: Text('제조사 전체'),
),
..._controller.vendorOptions.map( ..._controller.vendorOptions.map(
(vendor) => ShadOption<int?>( (vendor) => ShadOption<int?>(
value: vendor.id, value: vendor.id,
@@ -219,12 +258,10 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
); );
return Text(uom.uomName); return Text(uom.uomName);
}, },
onChanged: (value) => _controller.updateUomFilter(value), onChanged: (value) =>
_updateRoute(page: 1, uomOverride: value),
options: [ options: [
const ShadOption<int?>( const ShadOption<int?>(value: null, child: Text('단위 전체')),
value: null,
child: Text('단위 전체'),
),
..._controller.uomOptions.map( ..._controller.uomOptions.map(
(uom) => ShadOption<int?>( (uom) => ShadOption<int?>(
value: uom.id, value: uom.id,
@@ -243,7 +280,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
Text(_statusLabel(filter)), Text(_statusLabel(filter)),
onChanged: (value) { onChanged: (value) {
if (value == null) return; if (value == null) return;
_controller.updateStatusFilter(value); _updateRoute(page: 1, statusOverride: value);
}, },
options: ProductStatusFilter.values options: ProductStatusFilter.values
.map( .map(
@@ -255,27 +292,6 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
.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.updateVendorFilter(null);
_controller.updateUomFilter(null);
_controller.updateStatusFilter(
ProductStatusFilter.all,
);
_controller.fetch(page: 1);
},
child: const Text('초기화'),
),
], ],
), ),
child: ShadCard( child: ShadCard(
@@ -299,7 +315,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
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),
@@ -307,7 +323,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
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('다음'),
), ),
], ],
@@ -334,9 +350,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
? null ? null
: (product) => : (product) =>
_openProductForm(context, product: product), _openProductForm(context, product: product),
onDelete: _controller.isSubmitting onDelete: _controller.isSubmitting ? null : _confirmDelete,
? null
: _confirmDelete,
onRestore: _controller.isSubmitting onRestore: _controller.isSubmitting
? null ? null
: _restoreProduct, : _restoreProduct,
@@ -348,8 +362,9 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
} }
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(ProductStatusFilter filter) { String _statusLabel(ProductStatusFilter filter) {
@@ -363,6 +378,126 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
} }
} }
void _maybeApplyRoute() {
final signature = widget.routeUri.toString();
if (_lastRouteSignature == signature) {
return;
}
_lastRouteSignature = signature;
_applyRouteParameters();
}
void _applyRouteParameters() {
final params = widget.routeUri.queryParameters;
final query = params['q'] ?? '';
final vendorParam = int.tryParse(params['vendor'] ?? '');
final uomParam = int.tryParse(params['uom'] ?? '');
final status = _statusFromParam(params['status']);
final pageSizeParam = int.tryParse(params['page_size'] ?? '');
final pageParam = int.tryParse(params['page'] ?? '');
_searchController.text = query;
_controller.updateQuery(query);
_controller.updateVendorFilter(vendorParam);
_controller.updateUomFilter(uomParam);
_controller.updateStatusFilter(status);
if (pageSizeParam != null && pageSizeParam > 0) {
_controller.updatePageSize(pageSizeParam);
}
final page = pageParam != null && pageParam > 0 ? pageParam : 1;
WidgetsBinding.instance.addPostFrameCallback((_) async {
setState(() {
_lookupsLoaded = false;
});
await Future.wait([
_controller.loadLookups(),
_controller.fetch(page: page),
]);
if (mounted) {
setState(() {
_lookupsLoaded = true;
});
}
});
}
void _goToPage(int page) {
if (page < 1) {
page = 1;
}
_updateRoute(page: page);
}
void _updateRoute({
required int page,
String? queryOverride,
int? vendorOverride,
int? uomOverride,
ProductStatusFilter? statusOverride,
int? pageSizeOverride,
}) {
final query = queryOverride ?? _controller.query;
final vendor = vendorOverride ?? _controller.vendorFilter;
final uom = uomOverride ?? _controller.uomFilter;
final status = statusOverride ?? _controller.statusFilter;
final pageSize = pageSizeOverride ?? _controller.pageSize;
final params = <String, String>{};
if (query.isNotEmpty) {
params['q'] = query;
}
if (vendor != null) {
params['vendor'] = vendor.toString();
}
if (uom != null) {
params['uom'] = uom.toString();
}
final statusParam = _encodeStatus(status);
if (statusParam != null) {
params['status'] = statusParam;
}
if (page > 1) {
params['page'] = page.toString();
}
if (pageSize > 0 && pageSize != ProductController.defaultPageSize) {
params['page_size'] = pageSize.toString();
}
final uri = Uri(
path: widget.routeUri.path,
queryParameters: params.isEmpty ? null : params,
);
final newLocation = uri.toString();
if (widget.routeUri.toString() == newLocation) {
_controller.fetch(page: page);
return;
}
GoRouter.of(context).go(newLocation);
}
ProductStatusFilter _statusFromParam(String? value) {
switch (value) {
case 'active':
return ProductStatusFilter.activeOnly;
case 'inactive':
return ProductStatusFilter.inactiveOnly;
default:
return ProductStatusFilter.all;
}
}
String? _encodeStatus(ProductStatusFilter filter) {
switch (filter) {
case ProductStatusFilter.all:
return null;
case ProductStatusFilter.activeOnly:
return 'active';
case ProductStatusFilter.inactiveOnly:
return 'inactive';
}
}
Future<void> _openProductForm( Future<void> _openProductForm(
BuildContext context, { BuildContext context, {
Product? product, Product? product,
@@ -398,38 +533,15 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
final vendorError = ValueNotifier<String?>(null); final vendorError = ValueNotifier<String?>(null);
final uomError = ValueNotifier<String?>(null); final uomError = ValueNotifier<String?>(null);
await showDialog<bool>( await SuperportDialog.show<bool>(
context: parentContext, context: parentContext,
builder: (dialogContext) { dialog: SuperportDialog(
final theme = ShadTheme.of(dialogContext); title: isEdit ? '제품 수정' : '제품 등록',
final materialTheme = Theme.of(dialogContext); description: '제품 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.',
final navigator = Navigator.of(dialogContext); primaryAction: ValueListenableBuilder<bool>(
return Dialog(
insetPadding: const EdgeInsets.all(24),
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: ShadCard(
title: Text(
isEdit ? '제품 수정' : '제품 등록',
style: theme.textTheme.h3,
),
description: Text(
'제품 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.',
style: theme.textTheme.muted,
),
footer: ValueListenableBuilder<bool>(
valueListenable: saving, valueListenable: saving,
builder: (_, isSaving, __) { builder: (context, isSaving, _) {
return Row( return ShadButton(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ShadButton.ghost(
onPressed: isSaving ? null : () => navigator.pop(false),
child: const Text('취소'),
),
const SizedBox(width: 12),
ShadButton(
onPressed: isSaving onPressed: isSaving
? null ? null
: () async { : () async {
@@ -439,18 +551,12 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
final vendorId = vendorNotifier.value; final vendorId = vendorNotifier.value;
final uomId = uomNotifier.value; final uomId = uomNotifier.value;
codeError.value = code.isEmpty codeError.value = code.isEmpty ? '제품코드를 입력하세요.' : null;
? '제품코드를 입력하세요.' nameError.value = name.isEmpty ? '제품명을 입력하세요.' : null;
: null;
nameError.value = name.isEmpty
? '제품명을 입력하세요.'
: null;
vendorError.value = vendorId == null vendorError.value = vendorId == null
? '제조사를 선택하세요.' ? '제조사를 선택하세요.'
: null; : null;
uomError.value = uomId == null uomError.value = uomId == null ? '단위를 선택하세요.' : null;
? '단위를 선택하세요.'
: null;
if (codeError.value != null || if (codeError.value != null ||
nameError.value != null || nameError.value != null ||
@@ -468,33 +574,40 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
isActive: isActiveNotifier.value, isActive: isActiveNotifier.value,
note: note.isEmpty ? null : note, note: note.isEmpty ? null : note,
); );
final navigator = Navigator.of(context);
final response = isEdit final response = isEdit
? await _controller.update( ? await _controller.update(productId!, input)
productId!,
input,
)
: await _controller.create(input); : await _controller.create(input);
saving.value = false; saving.value = false;
if (response != null) { if (response != null && mounted) {
if (!navigator.mounted) { if (!navigator.mounted) {
return; return;
} }
if (mounted) { _showSnack(isEdit ? '제품을 수정했습니다.' : '제품을 등록했습니다.');
_showSnack(
isEdit ? '제품을 수정했습니다.' : '제품을 등록했습니다.',
);
}
navigator.pop(true); navigator.pop(true);
} }
}, },
child: Text(isEdit ? '저장' : '등록'), child: Text(isEdit ? '저장' : '등록'),
),
],
); );
}, },
), ),
child: SingleChildScrollView( secondaryAction: ValueListenableBuilder<bool>(
padding: const EdgeInsets.only(right: 12), valueListenable: saving,
builder: (context, isSaving, _) {
return ShadButton.ghost(
onPressed: isSaving
? null
: () => Navigator.of(context).pop(false),
child: const Text('취소'),
);
},
),
child: ValueListenableBuilder<bool>(
valueListenable: saving,
builder: (context, isSaving, _) {
final theme = ShadTheme.of(context);
final materialTheme = Theme.of(context);
return SingleChildScrollView(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -614,8 +727,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
child: Text( child: Text(
errorText, errorText,
style: theme.textTheme.small.copyWith( style: theme.textTheme.small.copyWith(
color: color: materialTheme.colorScheme.error,
materialTheme.colorScheme.error,
), ),
), ),
), ),
@@ -674,8 +786,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
child: Text( child: Text(
errorText, errorText,
style: theme.textTheme.small.copyWith( style: theme.textTheme.small.copyWith(
color: color: materialTheme.colorScheme.error,
materialTheme.colorScheme.error,
), ),
), ),
), ),
@@ -726,11 +837,10 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
], ],
], ],
), ),
),
),
),
); );
}, },
),
),
); );
codeController.dispose(); codeController.dispose();
@@ -747,24 +857,20 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
} }
Future<void> _confirmDelete(Product product) async { Future<void> _confirmDelete(Product product) async {
final confirmed = await showDialog<bool>( final bool? confirmed = await SuperportDialog.show<bool>(
context: context, context: context,
builder: (dialogContext) { dialog: SuperportDialog(
return AlertDialog( title: '제품 삭제',
title: const Text('제품 삭제'), description: '"${product.productName}" 제품 삭제하시겠습니까?',
content: Text('"${product.productName}" 제품을 삭제하시겠습니까?'), primaryAction: ShadButton.destructive(
actions: [ onPressed: () => Navigator.of(context).pop(true),
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('취소'),
),
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('삭제'), child: const Text('삭제'),
), ),
], secondaryAction: ShadButton.ghost(
); onPressed: () => Navigator.of(context).pop(false),
}, child: const Text('취소'),
),
),
); );
if (confirmed == true && product.id != null) { if (confirmed == true && product.id != null) {
@@ -813,43 +919,47 @@ class _ProductTable 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('비고'),
'변경일시', Text('변경일시'),
'동작', Text('동작'),
].map((text) => ShadTableCell.header(child: Text(text))).toList(); ];
final rows = products.map((product) { final rows = products.map((product) {
return [ final cells = <Widget>[
product.id?.toString() ?? '-', Text(product.id?.toString() ?? '-'),
product.productCode, Text(product.productCode),
product.productName, Text(product.productName),
product.vendor?.vendorName ?? '-', Text(product.vendor?.vendorName ?? '-'),
product.uom?.uomName ?? '-', Text(product.uom?.uomName ?? '-'),
product.isActive ? 'Y' : 'N', Text(product.isActive ? 'Y' : 'N'),
product.isDeleted ? 'Y' : '-', Text(product.isDeleted ? 'Y' : '-'),
product.note?.isEmpty ?? true ? '-' : product.note!, Text(product.note?.isEmpty ?? true ? '-' : product.note!),
Text(
product.updatedAt == null product.updatedAt == null
? '-' ? '-'
: dateFormat.format(product.updatedAt!.toLocal()), : dateFormat.format(product.updatedAt!.toLocal()),
].map((text) => ShadTableCell(child: Text(text))).toList()..add( ),
];
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!(product), onPressed: onEdit == null ? null : () => onEdit!(product),
child: const Icon(LucideIcons.pencil, size: 16), child: const Icon(LucideIcons.pencil, size: 16),
), ),
const SizedBox(width: 8),
product.isDeleted product.isDeleted
? ShadButton.ghost( ? ShadButton.ghost(
size: ShadButtonSize.sm, size: ShadButtonSize.sm,
@@ -869,17 +979,18 @@ class _ProductTable extends StatelessWidget {
), ),
), ),
); );
return cells;
}).toList(); }).toList();
return SizedBox( return SuperportTable(
height: 56.0 * (products.length + 1), columns: columns,
child: ShadTable.list( rows: rows,
header: header, rowHeight: 56,
children: rows, maxHeight: 520,
columnSpanExtent: (index) => index == 9 columnSpanExtent: (index) => index == 9
? const FixedTableSpanExtent(160) ? const FixedTableSpanExtent(160)
: const FixedTableSpanExtent(140), : const FixedTableSpanExtent(140),
),
); );
} }
} }

View File

@@ -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);

View File

@@ -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('다음'),
), ),
], ],
@@ -249,14 +270,9 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
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
: _confirmDelete,
onRestore: _controller.isSubmitting
? null
: _restoreVendor,
dateFormat: _dateFormat, 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,38 +407,16 @@ 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);
return Dialog(
insetPadding: const EdgeInsets.all(24),
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520), constraints: const BoxConstraints(maxWidth: 520),
child: ShadCard( primaryAction: ValueListenableBuilder<bool>(
title: Text(
isEdit ? '벤더 수정' : '벤더 등록',
style: theme.textTheme.h3,
),
description: Text(
'벤더 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.',
style: theme.textTheme.muted,
),
footer: ValueListenableBuilder<bool>(
valueListenable: saving, valueListenable: saving,
builder: (_, isSaving, __) { builder: (context, isSaving, _) {
return Row( return ShadButton(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ShadButton.ghost(
onPressed: isSaving ? null : () => navigator.pop(false),
child: const Text('취소'),
),
const SizedBox(width: 12),
ShadButton(
onPressed: isSaving onPressed: isSaving
? null ? null
: () async { : () async {
@@ -345,15 +424,10 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
final name = nameController.text.trim(); final name = nameController.text.trim();
final note = noteController.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;
} }
@@ -364,29 +438,39 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
isActive: isActiveNotifier.value, isActive: isActiveNotifier.value,
note: note.isEmpty ? null : note, note: note.isEmpty ? null : note,
); );
final navigator = Navigator.of(context);
final response = isEdit final response = isEdit
? await _controller.update(vendorId!, input) ? await _controller.update(vendorId!, input)
: await _controller.create(input); : await _controller.create(input);
saving.value = false; saving.value = false;
if (response != null) { if (response != null && mounted) {
if (!navigator.mounted) { if (!navigator.mounted) {
return; return;
} }
if (mounted) { _showSnack(isEdit ? '벤더를 수정했습니다.' : '벤더를 등록했습니다.');
_showSnack(
isEdit ? '벤더를 수정했습니다.' : '벤더를 등록했습니다.',
);
}
navigator.pop(true); navigator.pop(true);
} }
}, },
child: Text(isEdit ? '저장' : '등록'), child: Text(isEdit ? '저장' : '등록'),
),
],
); );
}, },
), ),
child: Padding( secondaryAction: ValueListenableBuilder<bool>(
valueListenable: saving,
builder: (context, isSaving, _) {
return ShadButton.ghost(
onPressed: isSaving
? null
: () => Navigator.of(context).pop(false),
child: const Text('취소'),
);
},
),
child: Builder(
builder: (dialogContext) {
final theme = ShadTheme.of(dialogContext);
final materialTheme = Theme.of(dialogContext);
return Padding(
padding: const EdgeInsets.only(right: 12), padding: const EdgeInsets.only(right: 12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -496,11 +580,10 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
], ],
], ],
), ),
),
),
),
); );
}, },
),
),
); );
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: [
TextButton( ShadButton.ghost(
onPressed: () => Navigator.of(dialogContext).pop(false), onPressed: () => Navigator.of(context).pop(false),
child: const Text('취소'), child: const Text('취소'),
), ),
TextButton( ShadButton(
onPressed: () => Navigator.of(dialogContext).pop(true), onPressed: () => Navigator.of(context).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!),
Text(
vendor.updatedAt == null vendor.updatedAt == null
? '-' ? '-'
: dateFormat.format(vendor.updatedAt!.toLocal()), : dateFormat.format(vendor.updatedAt!.toLocal()),
].map((text) => ShadTableCell(child: Text(text))).toList()..add( ),
];
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),
),
); );
} }
} }

View File

@@ -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 {

View File

@@ -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 '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';
@@ -15,7 +18,9 @@ import '../../domain/repositories/warehouse_repository.dart';
import '../controllers/warehouse_controller.dart'; import '../controllers/warehouse_controller.dart';
class WarehousePage extends StatelessWidget { class WarehousePage extends StatelessWidget {
const WarehousePage({super.key}); const WarehousePage({super.key, required this.routeUri});
final Uri routeUri;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -72,12 +77,14 @@ class WarehousePage extends StatelessWidget {
); );
} }
return const _WarehouseEnabledPage(); return _WarehouseEnabledPage(routeUri: routeUri);
} }
} }
class _WarehouseEnabledPage extends StatefulWidget { class _WarehouseEnabledPage extends StatefulWidget {
const _WarehouseEnabledPage(); const _WarehouseEnabledPage({required this.routeUri});
final Uri routeUri;
@override @override
State<_WarehouseEnabledPage> createState() => _WarehouseEnabledPageState(); State<_WarehouseEnabledPage> createState() => _WarehouseEnabledPageState();
@@ -89,6 +96,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
final FocusNode _searchFocus = FocusNode(); final FocusNode _searchFocus = 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() {
@@ -96,9 +104,15 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
_controller = WarehouseController( _controller = WarehouseController(
repository: GetIt.I<WarehouseRepository>(), repository: GetIt.I<WarehouseRepository>(),
)..addListener(_handleControllerUpdate); )..addListener(_handleControllerUpdate);
WidgetsBinding.instance.addPostFrameCallback((_) { }
_controller.fetch();
}); @override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_routeApplied) {
_routeApplied = true;
_applyRouteParameters();
}
} }
void _handleControllerUpdate() { void _handleControllerUpdate() {
@@ -161,6 +175,31 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
), ),
], ],
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.updateStatusFilter(
WarehouseStatusFilter.all,
);
_updateRoute(
page: 1,
queryOverride: '',
statusOverride: WarehouseStatusFilter.all,
);
},
child: const Text('초기화'),
),
],
children: [ children: [
SizedBox( SizedBox(
width: 260, width: 260,
@@ -182,6 +221,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
onChanged: (value) { onChanged: (value) {
if (value == null) return; if (value == null) return;
_controller.updateStatusFilter(value); _controller.updateStatusFilter(value);
_updateRoute(page: 1, statusOverride: value);
}, },
options: WarehouseStatusFilter.values options: WarehouseStatusFilter.values
.map( .map(
@@ -193,25 +233,6 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
.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.updateStatusFilter(
WarehouseStatusFilter.all,
);
_controller.fetch(page: 1);
},
child: const Text('초기화'),
),
], ],
), ),
child: ShadCard( child: ShadCard(
@@ -235,7 +256,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
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),
@@ -243,7 +264,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
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('다음'),
), ),
], ],
@@ -282,8 +303,9 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
} }
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(WarehouseStatusFilter filter) { String _statusLabel(WarehouseStatusFilter filter) {
@@ -297,6 +319,90 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
} }
} }
void _applyRouteParameters() {
final params = widget.routeUri.queryParameters;
final query = params['q'] ?? '';
final status = _statusFromParam(params['status']);
final pageSizeParam = int.tryParse(params['page_size'] ?? '');
final pageParam = int.tryParse(params['page'] ?? '');
_searchController.text = query;
_controller.updateQuery(query);
_controller.updateStatusFilter(status);
if (pageSizeParam != null && pageSizeParam > 0) {
_controller.updatePageSize(pageSizeParam);
}
final page = pageParam != null && pageParam > 0 ? pageParam : 1;
_controller.fetch(page: page);
}
void _goToPage(int page) {
if (page < 1) {
page = 1;
}
_updateRoute(page: page);
}
void _updateRoute({
required int page,
String? queryOverride,
WarehouseStatusFilter? statusOverride,
int? pageSizeOverride,
}) {
final query = queryOverride ?? _controller.query;
final status = statusOverride ?? _controller.statusFilter;
final pageSize = pageSizeOverride ?? _controller.pageSize;
final params = <String, String>{};
if (query.isNotEmpty) {
params['q'] = query;
}
final statusParam = _encodeStatus(status);
if (statusParam != null) {
params['status'] = statusParam;
}
if (page > 1) {
params['page'] = page.toString();
}
if (pageSize > 0 && pageSize != WarehouseController.defaultPageSize) {
params['page_size'] = pageSize.toString();
}
final uri = Uri(
path: widget.routeUri.path,
queryParameters: params.isEmpty ? null : params,
);
final newLocation = uri.toString();
if (widget.routeUri.toString() == newLocation) {
_controller.fetch(page: page);
return;
}
GoRouter.of(context).go(newLocation);
}
WarehouseStatusFilter _statusFromParam(String? value) {
switch (value) {
case 'active':
return WarehouseStatusFilter.activeOnly;
case 'inactive':
return WarehouseStatusFilter.inactiveOnly;
default:
return WarehouseStatusFilter.all;
}
}
String? _encodeStatus(WarehouseStatusFilter filter) {
switch (filter) {
case WarehouseStatusFilter.all:
return null;
case WarehouseStatusFilter.activeOnly:
return 'active';
case WarehouseStatusFilter.inactiveOnly:
return 'inactive';
}
}
Future<void> _openWarehouseForm( Future<void> _openWarehouseForm(
BuildContext context, { BuildContext context, {
Warehouse? warehouse, Warehouse? warehouse,
@@ -337,13 +443,115 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
final saving = ValueNotifier<bool>(false); final saving = ValueNotifier<bool>(false);
final codeError = ValueNotifier<String?>(null); final codeError = ValueNotifier<String?>(null);
final nameError = ValueNotifier<String?>(null); final nameError = ValueNotifier<String?>(null);
final zipcodeError = ValueNotifier<String?>(null);
await showDialog<bool>( var isApplyingPostalSelection = false;
void handleZipcodeChange() {
if (isApplyingPostalSelection) {
return;
}
final text = zipcodeController.text.trim();
final selection = selectedPostalNotifier.value;
if (text.isEmpty) {
if (selection != null) {
selectedPostalNotifier.value = null;
}
zipcodeError.value = null;
return;
}
if (selection != null && selection.zipcode != text) {
selectedPostalNotifier.value = null;
}
if (zipcodeError.value != null) {
zipcodeError.value = null;
}
}
void handlePostalSelectionChange() {
if (selectedPostalNotifier.value != null) {
zipcodeError.value = null;
}
}
zipcodeController.addListener(handleZipcodeChange);
selectedPostalNotifier.addListener(handlePostalSelectionChange);
await SuperportDialog.show<bool>(
context: context, context: context,
dialog: SuperportDialog(
title: isEdit ? '창고 수정' : '창고 등록',
description: '창고 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.',
constraints: const BoxConstraints(maxWidth: 540),
primaryAction: ValueListenableBuilder<bool>(
valueListenable: saving,
builder: (context, isSaving, _) {
return ShadButton(
onPressed: isSaving
? null
: () async {
final code = codeController.text.trim();
final name = nameController.text.trim();
final zipcode = zipcodeController.text.trim();
final address = addressController.text.trim();
final note = noteController.text.trim();
final selectedPostal = selectedPostalNotifier.value;
codeError.value = code.isEmpty ? '창고코드를 입력하세요.' : null;
nameError.value = name.isEmpty ? '창고명을 입력하세요.' : null;
zipcodeError.value =
zipcode.isNotEmpty && selectedPostal == null
? '우편번호 검색으로 주소를 선택하세요.'
: null;
if (codeError.value != null ||
nameError.value != null ||
zipcodeError.value != null) {
return;
}
saving.value = true;
final input = WarehouseInput(
warehouseCode: code,
warehouseName: name,
zipcode: zipcode.isEmpty ? null : zipcode,
addressDetail: address.isEmpty ? null : address,
isActive: isActiveNotifier.value,
note: note.isEmpty ? null : note,
);
final navigator = Navigator.of(context);
final response = isEdit
? await _controller.update(warehouseId!, input)
: await _controller.create(input);
saving.value = false;
if (response != null && mounted) {
if (!navigator.mounted) {
return;
}
_showSnack(isEdit ? '창고를 수정했습니다.' : '창고를 등록했습니다.');
navigator.pop(true);
}
},
child: Text(isEdit ? '저장' : '등록'),
);
},
),
secondaryAction: ValueListenableBuilder<bool>(
valueListenable: saving,
builder: (context, isSaving, _) {
return ShadButton.ghost(
onPressed: isSaving
? null
: () => Navigator.of(context).pop(false),
child: const Text('취소'),
);
},
),
child: Builder(
builder: (dialogContext) { builder: (dialogContext) {
final theme = ShadTheme.of(dialogContext); final theme = ShadTheme.of(dialogContext);
final materialTheme = Theme.of(dialogContext); final materialTheme = Theme.of(dialogContext);
final navigator = Navigator.of(dialogContext);
Future<void> openPostalSearch() async { Future<void> openPostalSearch() async {
final keyword = zipcodeController.text.trim(); final keyword = zipcodeController.text.trim();
final result = await showPostalSearchDialog( final result = await showPostalSearchDialog(
@@ -353,11 +561,13 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
if (result == null) { if (result == null) {
return; return;
} }
isApplyingPostalSelection = true;
zipcodeController zipcodeController
..text = result.zipcode ..text = result.zipcode
..selection = TextSelection.collapsed( ..selection = TextSelection.collapsed(
offset: result.zipcode.length, offset: result.zipcode.length,
); );
isApplyingPostalSelection = false;
selectedPostalNotifier.value = result; selectedPostalNotifier.value = result;
if (result.fullAddress.isNotEmpty) { if (result.fullAddress.isNotEmpty) {
addressController addressController
@@ -368,90 +578,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
} }
} }
return Dialog( return SingleChildScrollView(
insetPadding: const EdgeInsets.all(24),
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 540),
child: ShadCard(
title: Text(
isEdit ? '창고 수정' : '창고 등록',
style: theme.textTheme.h3,
),
description: Text(
'창고 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.',
style: theme.textTheme.muted,
),
footer: ValueListenableBuilder<bool>(
valueListenable: saving,
builder: (_, isSaving, __) {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ShadButton.ghost(
onPressed: isSaving ? null : () => navigator.pop(false),
child: const Text('취소'),
),
const SizedBox(width: 12),
ShadButton(
onPressed: isSaving
? null
: () async {
final code = codeController.text.trim();
final name = nameController.text.trim();
final zipcode = zipcodeController.text.trim();
final address = addressController.text.trim();
final note = noteController.text.trim();
codeError.value = code.isEmpty
? '창고코드를 입력하세요.'
: null;
nameError.value = name.isEmpty
? '창고명을 입력하세요.'
: null;
if (codeError.value != null ||
nameError.value != null) {
return;
}
saving.value = true;
final input = WarehouseInput(
warehouseCode: code,
warehouseName: name,
zipcode: zipcode.isEmpty ? null : zipcode,
addressDetail: address.isEmpty
? null
: address,
isActive: isActiveNotifier.value,
note: note.isEmpty ? null : note,
);
final response = isEdit
? await _controller.update(
warehouseId!,
input,
)
: await _controller.create(input);
saving.value = false;
if (response != null) {
if (!navigator.mounted) {
return;
}
if (mounted) {
_showSnack(
isEdit ? '창고를 수정했습니다.' : '창고를 등록했습니다.',
);
}
navigator.pop(true);
}
},
child: Text(isEdit ? '저장' : '등록'),
),
],
);
},
),
child: SingleChildScrollView(
padding: const EdgeInsets.only(right: 12), padding: const EdgeInsets.only(right: 12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -522,7 +649,10 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_FormField( ValueListenableBuilder<String?>(
valueListenable: zipcodeError,
builder: (_, zipcodeErrorText, __) {
return _FormField(
label: '우편번호', label: '우편번호',
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -537,11 +667,16 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
ShadButton.outline( ValueListenableBuilder<bool>(
onPressed: saving.value valueListenable: saving,
builder: (_, isSaving, __) {
return ShadButton.outline(
onPressed: isSaving
? null ? null
: openPostalSearch, : openPostalSearch,
child: const Text('검색'), child: const Text('검색'),
);
},
), ),
], ],
), ),
@@ -566,8 +701,20 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
); );
}, },
), ),
if (zipcodeErrorText != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
zipcodeErrorText,
style: theme.textTheme.small.copyWith(
color: materialTheme.colorScheme.error,
),
),
),
], ],
), ),
);
},
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_FormField( _FormField(
@@ -617,13 +764,15 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
], ],
], ],
), ),
),
),
),
); );
}, },
),
),
); );
zipcodeController.removeListener(handleZipcodeChange);
selectedPostalNotifier.removeListener(handlePostalSelectionChange);
if (!mounted) { if (!mounted) {
codeController.dispose(); codeController.dispose();
nameController.dispose(); nameController.dispose();
@@ -635,6 +784,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
saving.dispose(); saving.dispose();
codeError.dispose(); codeError.dispose();
nameError.dispose(); nameError.dispose();
zipcodeError.dispose();
return; return;
} }
@@ -648,27 +798,26 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
saving.dispose(); saving.dispose();
codeError.dispose(); codeError.dispose();
nameError.dispose(); nameError.dispose();
zipcodeError.dispose();
} }
Future<void> _confirmDelete(Warehouse warehouse) async { Future<void> _confirmDelete(Warehouse warehouse) 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: '"${warehouse.warehouseName}" 창고 삭제하시겠습니까?',
content: Text('"${warehouse.warehouseName}" 창고를 삭제하시겠습니까?'),
actions: [ actions: [
TextButton( ShadButton.ghost(
onPressed: () => Navigator.of(dialogContext).pop(false), onPressed: () => Navigator.of(context).pop(false),
child: const Text('취소'), child: const Text('취소'),
), ),
TextButton( ShadButton(
onPressed: () => Navigator.of(dialogContext).pop(true), onPressed: () => Navigator.of(context).pop(true),
child: const Text('삭제'), child: const Text('삭제'),
), ),
], ],
); ),
},
); );
if (confirmed == true && warehouse.id != null) { if (confirmed == true && warehouse.id != null) {
@@ -717,45 +866,51 @@ class _WarehouseTable 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('비고'),
'변경일시', Text('변경일시'),
'동작', Text('동작'),
].map((text) => ShadTableCell.header(child: Text(text))).toList(); ];
final rows = warehouses.map((warehouse) { final rows = warehouses.map((warehouse) {
return [ final cells = <Widget>[
warehouse.id?.toString() ?? '-', Text(warehouse.id?.toString() ?? '-'),
warehouse.warehouseCode, Text(warehouse.warehouseCode),
warehouse.warehouseName, Text(warehouse.warehouseName),
warehouse.zipcode?.zipcode ?? '-', Text(warehouse.zipcode?.zipcode ?? '-'),
Text(
warehouse.addressDetail?.isEmpty ?? true warehouse.addressDetail?.isEmpty ?? true
? '-' ? '-'
: warehouse.addressDetail!, : warehouse.addressDetail!,
warehouse.isActive ? 'Y' : 'N', ),
warehouse.isDeleted ? 'Y' : '-', Text(warehouse.isActive ? 'Y' : 'N'),
warehouse.note?.isEmpty ?? true ? '-' : warehouse.note!, Text(warehouse.isDeleted ? 'Y' : '-'),
Text(warehouse.note?.isEmpty ?? true ? '-' : warehouse.note!),
Text(
warehouse.updatedAt == null warehouse.updatedAt == null
? '-' ? '-'
: dateFormat.format(warehouse.updatedAt!.toLocal()), : dateFormat.format(warehouse.updatedAt!.toLocal()),
].map((text) => ShadTableCell(child: Text(text))).toList()..add( ),
];
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!(warehouse), onPressed: onEdit == null ? null : () => onEdit!(warehouse),
child: const Icon(LucideIcons.pencil, size: 16), child: const Icon(LucideIcons.pencil, size: 16),
), ),
const SizedBox(width: 8),
warehouse.isDeleted warehouse.isDeleted
? ShadButton.ghost( ? ShadButton.ghost(
size: ShadButtonSize.sm, size: ShadButtonSize.sm,
@@ -775,17 +930,18 @@ class _WarehouseTable extends StatelessWidget {
), ),
), ),
); );
return cells;
}).toList(); }).toList();
return SizedBox( return SuperportTable(
height: 56.0 * (warehouses.length + 1), columns: columns,
child: ShadTable.list( rows: rows,
header: header, rowHeight: 56,
children: rows, maxHeight: 520,
columnSpanExtent: (index) => index == 9 columnSpanExtent: (index) => index == 9
? const FixedTableSpanExtent(160) ? const FixedTableSpanExtent(160)
: const FixedTableSpanExtent(140), : const FixedTableSpanExtent(140),
),
); );
} }
} }

View File

@@ -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: '필터를 적용하거나 보고서를 다운로드하면 이 영역에 요약이 표시됩니다.',
), ),
), ),
), ),

View File

@@ -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,
),
],
),
),
],
),
),
),
),
); );
} }
} }

View File

@@ -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: '검색 결과가 없습니다.',
), ),

View File

@@ -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>(

View File

@@ -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,11 +16,40 @@ 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 PermissionScope(
manager: _permissionManager,
child: ThemeControllerScope(
controller: _themeController,
child: AnimatedBuilder(
animation: _themeController,
builder: (context, _) {
return ShadApp.router( return ShadApp.router(
title: 'Superport v2', title: 'Superport v2',
routerConfig: appRouter, routerConfig: appRouter,
@@ -28,13 +60,12 @@ class SuperportApp extends StatelessWidget {
GlobalWidgetsLocalizations.delegate, GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
], ],
theme: ShadThemeData( theme: SuperportShadTheme.light(),
colorScheme: const ShadSlateColorScheme.light(), darkTheme: SuperportShadTheme.dark(),
brightness: Brightness.light, themeMode: _themeController.mode,
);
},
), ),
darkTheme: ShadThemeData(
colorScheme: const ShadSlateColorScheme.dark(),
brightness: Brightness.dark,
), ),
); );
} }

View File

@@ -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,
), ),
); );

View File

@@ -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(

View File

@@ -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!],
], ],
), ),
); );

View 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),
],
],
),
);
}
}

View File

@@ -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;
} }

View 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,
),
),
),
],
);
}
}

View File

@@ -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!],
], ],
); );
} }

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -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: const Text('닫기'),
),
],
),
child: body, child: body,
), ),
); );
},
);
} }

View File

@@ -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) {
late final List<ShadTableCell> headerCells;
late final List<List<ShadTableCell>> tableRows;
if (_rowCells case final rows?) {
if (rows.isEmpty) { if (rows.isEmpty) {
final theme = ShadTheme.of(context); final theme = ShadTheme.of(context);
return Padding( return Padding(
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(32),
child: Center( child: Center(child: Text(emptyLabel, style: theme.textTheme.muted)),
child: Text(emptyLabel, style: theme.textTheme.muted),
),
); );
} }
final header = _headerCells;
final tableRows = [ if (header == null) {
for (final row in rows) throw StateError('header cells must not be null when using fromCells');
row }
.map( headerCells = header;
(cell) => cell is ShadTableCell ? cell : ShadTableCell(child: cell), tableRows = rows;
) } else {
.toList(), final rows = _rows;
]; if (rows == null || rows.isEmpty) {
final theme = ShadTheme.of(context);
return ShadTable.list( return Padding(
header: columns 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();
tableRows = [
for (final row in rows)
row
.map(
(cell) =>
cell is ShadTableCell ? cell : ShadTableCell(child: cell),
)
.toList(), .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, columnSpanExtent: columnSpanExtent,
rowSpanExtent: (_) => FixedTableSpanExtent(rowHeight), rowSpanExtent: (_) => FixedTableSpanExtent(rowHeight),
onRowTap: onRowTap, onRowTap: onRowTap,
primary: false,
children: tableRows, children: tableRows,
),
); );
} }
} }

View File

@@ -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);
} }

View File

@@ -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

View File

@@ -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"))
} }

View File

@@ -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:

View File

@@ -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:

View 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);
});
}

View File

@@ -55,6 +55,5 @@ void main() {
repository = _MockApprovalRepository(); repository = _MockApprovalRepository();
GetIt.I.registerLazySingleton<ApprovalRepository>(() => repository); GetIt.I.registerLazySingleton<ApprovalRepository>(() => repository);
}); });
}); });
} }

View File

@@ -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(

View File

@@ -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);
}); });
}); });

View File

@@ -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('신규 등록'));

View File

@@ -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('등록'));

View File

@@ -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('신규 등록'));

View File

@@ -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);

View File

@@ -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();
});
}

View 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),
),
);
}

View 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);
});
}

View 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
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
# 프로젝트 전체를 포맷합니다. 추가 인자 없이 실행하면 현재 디렉터리 기준으로 진행됩니다.
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$DIR"
dart format .

View File

@@ -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"));
} }

View File

@@ -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