diff --git a/doc/input_widget_guide.md b/doc/input_widget_guide.md new file mode 100644 index 0000000..8ddc044 --- /dev/null +++ b/doc/input_widget_guide.md @@ -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( + 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`를 그대로 사용하고, `SuperportFormField`로 라벨만 감싼다. +- 다중 선택이 필요한 경우 `ShadSelect.multiple` 과 토큰(UiChip) 스타일을 조합한다. +- 최초 옵션은 `전체`/`선택하세요`처럼 명확한 기본값을 제공한다. + +```dart +SuperportFormField( + label: '상태', + required: true, + child: ShadSelect( + 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` — 입고 등록 모달 + +위 가이드를 준수하면 폼 간 스타일과 상호작용 규칙을 동일하게 유지할 수 있다. diff --git a/lib/core/config/environment.dart b/lib/core/config/environment.dart index da14037..0d4e073 100644 --- a/lib/core/config/environment.dart +++ b/lib/core/config/environment.dart @@ -18,12 +18,17 @@ class Environment { /// 프로덕션 여부 static late final bool isProduction; + static final Map> _permissions = {}; + /// 환경 초기화 /// /// - 기본 환경은 development이며, `ENV` dart-define 으로 변경 가능 /// - 해당 환경의 .env 파일을 로드하고 핵심 값을 추출한다. static Future initialize() async { - const envFromDefine = String.fromEnvironment('ENV', defaultValue: 'development'); + const envFromDefine = String.fromEnvironment( + 'ENV', + defaultValue: 'development', + ); envName = envFromDefine.toLowerCase(); isProduction = envName == 'production'; @@ -46,6 +51,7 @@ class Environment { } baseUrl = dotenv.maybeGet('API_BASE_URL') ?? 'http://localhost:8080'; + _loadPermissions(); } /// 기능 플래그 조회 (기본 false) @@ -67,4 +73,32 @@ class Environment { return defaultValue; } } + + static void _loadPermissions() { + _permissions.clear(); + for (final entry in dotenv.env.entries) { + const prefix = 'PERMISSION__'; + if (!entry.key.startsWith(prefix)) { + continue; + } + final resource = entry.key.substring(prefix.length).toLowerCase(); + final values = entry.value + .split(',') + .map((token) => token.trim().toLowerCase()) + .where((token) => token.isNotEmpty) + .toSet(); + _permissions[resource] = values; + } + } + + static bool hasPermission(String resource, String action) { + final actions = _permissions[resource.toLowerCase()]; + if (actions == null || actions.isEmpty) { + return true; + } + if (actions.contains('all')) { + return true; + } + return actions.contains(action.toLowerCase()); + } } diff --git a/lib/core/constants/app_sections.dart b/lib/core/constants/app_sections.dart index f948dec..491f0a1 100644 --- a/lib/core/constants/app_sections.dart +++ b/lib/core/constants/app_sections.dart @@ -1,5 +1,5 @@ import 'package:flutter/widgets.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; class AppPageDescriptor { const AppPageDescriptor({ @@ -32,7 +32,7 @@ const appSections = [ AppPageDescriptor( path: dashboardRoutePath, label: '대시보드', - icon: LucideIcons.layoutDashboard, + icon: lucide.LucideIcons.layoutDashboard, summary: '오늘 입고/출고, 결재 대기, 최근 트랜잭션을 한 화면에서 확인합니다.', ), ], @@ -43,19 +43,19 @@ const appSections = [ AppPageDescriptor( path: '/inventory/inbound', label: '입고', - icon: LucideIcons.packagePlus, + icon: lucide.LucideIcons.packagePlus, summary: '입고 처리 기본정보와 라인 품목을 등록하고 검토합니다.', ), AppPageDescriptor( path: '/inventory/outbound', label: '출고', - icon: LucideIcons.packageMinus, + icon: lucide.LucideIcons.packageMinus, summary: '출고 품목, 고객사 연결, 상태 변경을 관리합니다.', ), AppPageDescriptor( path: '/inventory/rental', label: '대여', - icon: LucideIcons.handshake, + icon: lucide.LucideIcons.handshake, summary: '대여/반납 구분과 반납예정일을 포함한 대여 흐름입니다.', ), ], @@ -66,49 +66,49 @@ const appSections = [ AppPageDescriptor( path: '/masters/vendors', label: '제조사 관리', - icon: LucideIcons.factory, + icon: lucide.LucideIcons.factory, summary: '벤더코드, 명칭, 사용여부 등을 유지합니다.', ), AppPageDescriptor( path: '/masters/products', label: '장비 모델 관리', - icon: LucideIcons.box, + icon: lucide.LucideIcons.box, summary: '제품코드, 제조사, 단위 정보를 관리합니다.', ), AppPageDescriptor( path: '/masters/warehouses', label: '입고지 관리', - icon: LucideIcons.warehouse, + icon: lucide.LucideIcons.warehouse, summary: '창고 주소와 사용여부를 설정합니다.', ), AppPageDescriptor( path: '/masters/customers', label: '회사 관리', - icon: LucideIcons.building, + icon: lucide.LucideIcons.building, summary: '고객사 연락처와 주소 정보를 관리합니다.', ), AppPageDescriptor( path: '/masters/users', label: '사용자 관리', - icon: LucideIcons.users, + icon: lucide.LucideIcons.users, summary: '사번, 그룹, 사용여부를 관리합니다.', ), AppPageDescriptor( path: '/masters/groups', label: '그룹 관리', - icon: LucideIcons.layers, + icon: lucide.LucideIcons.layers, summary: '권한 그룹과 설명, 기본여부를 정의합니다.', ), AppPageDescriptor( path: '/masters/menus', label: '메뉴 관리', - icon: LucideIcons.listTree, + icon: lucide.LucideIcons.listTree, summary: '메뉴 계층과 경로, 노출 순서를 구성합니다.', ), AppPageDescriptor( path: '/masters/group-permissions', label: '그룹 메뉴 권한', - icon: LucideIcons.shieldCheck, + icon: lucide.LucideIcons.shieldCheck, summary: '그룹별 메뉴 CRUD 권한을 설정합니다.', ), ], @@ -119,25 +119,25 @@ const appSections = [ AppPageDescriptor( path: '/approvals/requests', label: '결재 관리', - icon: LucideIcons.fileCheck, + icon: lucide.LucideIcons.fileCheck, summary: '결재 번호, 상태, 상신자를 관리합니다.', ), AppPageDescriptor( path: '/approvals/steps', label: '결재 단계', - icon: LucideIcons.workflow, + icon: lucide.LucideIcons.workflow, summary: '단계 순서와 승인자 할당을 설정합니다.', ), AppPageDescriptor( path: '/approvals/history', label: '결재 이력', - icon: LucideIcons.history, + icon: lucide.LucideIcons.history, summary: '결재 단계별 변경 이력을 조회합니다.', ), AppPageDescriptor( path: '/approvals/templates', label: '결재 템플릿', - icon: LucideIcons.fileSpreadsheet, + icon: lucide.LucideIcons.fileSpreadsheet, summary: '반복되는 결재 흐름을 템플릿으로 관리합니다.', ), ], @@ -148,7 +148,7 @@ const appSections = [ AppPageDescriptor( path: '/utilities/postal-search', label: '우편번호 검색', - icon: LucideIcons.search, + icon: lucide.LucideIcons.search, summary: '모달 기반 우편번호 검색 도구입니다.', ), ], @@ -159,7 +159,7 @@ const appSections = [ AppPageDescriptor( path: '/reports', label: '보고서', - icon: LucideIcons.fileDown, + icon: lucide.LucideIcons.fileDown, summary: '조건 필터와 PDF/XLSX 다운로드 기능입니다.', ), ], diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart index f25f2d5..900977e 100644 --- a/lib/core/network/api_client.dart +++ b/lib/core/network/api_client.dart @@ -2,18 +2,23 @@ import 'package:dio/dio.dart'; +import 'api_error.dart'; + /// 공통 API 클라이언트 (Dio 래퍼) /// - 모든 HTTP 호출은 이 클래스를 통해 이루어진다. /// - BaseURL/타임아웃/인증/로깅/에러 처리 등을 중앙집중화한다. class ApiClient { + ApiClient({required Dio dio, ApiErrorMapper? errorMapper}) + : _dio = dio, + _errorMapper = errorMapper ?? const ApiErrorMapper(); + final Dio _dio; + final ApiErrorMapper _errorMapper; /// 내부에서 사용하는 Dio 인스턴스 /// 외부에서 Dio 직접 사용을 최소화하고, 가능하면 아래 헬퍼 메서드를 사용한다. Dio get dio => _dio; - ApiClient({required Dio dio}) : _dio = dio; - /// GET 요청 헬퍼 Future> get( String path, { @@ -21,7 +26,14 @@ class ApiClient { Options? options, CancelToken? cancelToken, }) { - return _dio.get(path, queryParameters: query, options: options, cancelToken: cancelToken); + return _wrap( + () => _dio.get( + path, + queryParameters: query, + options: options, + cancelToken: cancelToken, + ), + ); } /// POST 요청 헬퍼 @@ -32,7 +44,15 @@ class ApiClient { Options? options, CancelToken? cancelToken, }) { - return _dio.post(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken); + return _wrap( + () => _dio.post( + path, + data: data, + queryParameters: query, + options: options, + cancelToken: cancelToken, + ), + ); } /// PATCH 요청 헬퍼 @@ -43,7 +63,15 @@ class ApiClient { Options? options, CancelToken? cancelToken, }) { - return _dio.patch(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken); + return _wrap( + () => _dio.patch( + path, + data: data, + queryParameters: query, + options: options, + cancelToken: cancelToken, + ), + ); } /// DELETE 요청 헬퍼 @@ -54,7 +82,22 @@ class ApiClient { Options? options, CancelToken? cancelToken, }) { - return _dio.delete(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken); + return _wrap( + () => _dio.delete( + path, + data: data, + queryParameters: query, + options: options, + cancelToken: cancelToken, + ), + ); + } + + Future> _wrap(Future> Function() request) async { + try { + return await request(); + } on DioException catch (error) { + throw _errorMapper.map(error); + } } } - diff --git a/lib/core/network/api_error.dart b/lib/core/network/api_error.dart new file mode 100644 index 0000000..522ed6f --- /dev/null +++ b/lib/core/network/api_error.dart @@ -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? 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) { + 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? _extractDetails(dynamic data) { + if (data is Map) { + final errors = data['errors']; + if (errors is Map) { + return errors; + } + } + return null; + } +} diff --git a/lib/core/network/interceptors/auth_interceptor.dart b/lib/core/network/interceptors/auth_interceptor.dart index 14f2730..f8abdcb 100644 --- a/lib/core/network/interceptors/auth_interceptor.dart +++ b/lib/core/network/interceptors/auth_interceptor.dart @@ -1,29 +1,124 @@ // ignore_for_file: public_member_api_docs +import 'dart:async'; + import 'package:dio/dio.dart'; -/// 인증 인터셉터(스켈레톤) +import '../../services/token_storage.dart'; + +typedef RefreshTokenCallback = Future Function(); + +class TokenPair { + const TokenPair({required this.accessToken, required this.refreshToken}); + + final String accessToken; + final String refreshToken; +} + +/// 인증 인터셉터 /// - 요청 전에 Authorization 헤더 주입 -/// - 401 수신 시 토큰 갱신 및 원요청 1회 재시도 (구현 예정) +/// - 401 수신 시 토큰 갱신 및 원요청 1회 재시도 class AuthInterceptor extends Interceptor { - /// TODO: 토큰 저장/조회 서비스 주입 (예: AuthRepository) - AuthInterceptor(); + AuthInterceptor({ + required TokenStorage tokenStorage, + required Dio dio, + this.onRefresh, + }) : _tokenStorage = tokenStorage, + _dio = dio; + + final TokenStorage _tokenStorage; + final Dio _dio; + final RefreshTokenCallback? onRefresh; + + final List> _refreshQueue = []; + bool _isRefreshing = false; @override - void onRequest(RequestOptions options, RequestInterceptorHandler handler) async { - // TODO: 저장된 토큰을 읽어 Authorization 헤더에 주입한다. - // final token = await _authRepository.getToken(); - // if (token != null && token.isNotEmpty) { - // options.headers['Authorization'] = 'Bearer $token'; - // } + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + final token = await _tokenStorage.readAccessToken(); + if (token != null && token.isNotEmpty) { + options.headers['Authorization'] = 'Bearer $token'; + } handler.next(options); } @override - void onError(DioException err, ErrorInterceptorHandler handler) async { - // TODO: 401 처리 로직(토큰 갱신 → 원요청 재시도) 구현 - // if (err.response?.statusCode == 401) { ... } - handler.next(err); + Future onError( + DioException err, + ErrorInterceptorHandler handler, + ) async { + if (!_shouldAttemptRefresh(err)) { + handler.next(err); + return; + } + + try { + await _refreshToken(); + final response = await _retry(err.requestOptions); + handler.resolve(response); + } on _RefreshFailedException { + await _tokenStorage.clear(); + handler.next(err); + } on DioException catch (e) { + handler.next(e); + } catch (_) { + handler.next(err); + } + } + + bool _shouldAttemptRefresh(DioException err) { + return onRefresh != null && + err.response?.statusCode == 401 && + err.requestOptions.extra['__retry'] != true; + } + + Future _refreshToken() async { + if (_isRefreshing) { + final completer = Completer(); + _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> _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(); +} diff --git a/lib/core/permissions/permission_manager.dart b/lib/core/permissions/permission_manager.dart new file mode 100644 index 0000000..e936c37 --- /dev/null +++ b/lib/core/permissions/permission_manager.dart @@ -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>? overrides}) { + if (overrides != null) { + _overrides.addAll(overrides); + } + } + + final Map> _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> overrides) { + _overrides + ..clear() + ..addAll(overrides); + notifyListeners(); + } +} + +class PermissionScope extends InheritedNotifier { + const PermissionScope({ + super.key, + required PermissionManager manager, + required super.child, + }) : super(notifier: manager); + + static PermissionManager of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType(); + 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; +} diff --git a/lib/core/routing/app_router.dart b/lib/core/routing/app_router.dart index 8236297..e3ca599 100644 --- a/lib/core/routing/app_router.dart +++ b/lib/core/routing/app_router.dart @@ -47,32 +47,32 @@ final appRouter = GoRouter( GoRoute( path: '/inventory/inbound', name: 'inventory-inbound', - builder: (context, state) => const InboundPage(), + builder: (context, state) => InboundPage(routeUri: state.uri), ), GoRoute( path: '/inventory/outbound', name: 'inventory-outbound', - builder: (context, state) => const OutboundPage(), + builder: (context, state) => OutboundPage(routeUri: state.uri), ), GoRoute( path: '/inventory/rental', name: 'inventory-rental', - builder: (context, state) => const RentalPage(), + builder: (context, state) => RentalPage(routeUri: state.uri), ), GoRoute( path: '/masters/vendors', name: 'masters-vendors', - builder: (context, state) => const VendorPage(), + builder: (context, state) => VendorPage(routeUri: state.uri), ), GoRoute( path: '/masters/products', name: 'masters-products', - builder: (context, state) => const ProductPage(), + builder: (context, state) => ProductPage(routeUri: state.uri), ), GoRoute( path: '/masters/warehouses', name: 'masters-warehouses', - builder: (context, state) => const WarehousePage(), + builder: (context, state) => WarehousePage(routeUri: state.uri), ), GoRoute( path: '/masters/customers', diff --git a/lib/core/services/token_storage.dart b/lib/core/services/token_storage.dart new file mode 100644 index 0000000..076f959 --- /dev/null +++ b/lib/core/services/token_storage.dart @@ -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 writeAccessToken(String? token); + + Future readAccessToken(); + + Future writeRefreshToken(String? token); + + Future readRefreshToken(); + + Future clear(); +} + +/// 플랫폼에 맞는 스토리지 구현체를 생성한다. +TokenStorage createTokenStorage() => buildTokenStorage(); diff --git a/lib/core/services/token_storage_native.dart b/lib/core/services/token_storage_native.dart new file mode 100644 index 0000000..50da20e --- /dev/null +++ b/lib/core/services/token_storage_native.dart @@ -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 clear() async { + await _storage.delete(key: _kAccessTokenKey); + await _storage.delete(key: _kRefreshTokenKey); + } + + @override + Future readAccessToken() => _storage.read(key: _kAccessTokenKey); + + @override + Future readRefreshToken() => _storage.read(key: _kRefreshTokenKey); + + @override + Future writeAccessToken(String? token) async { + if (token == null || token.isEmpty) { + await _storage.delete(key: _kAccessTokenKey); + return; + } + await _storage.write(key: _kAccessTokenKey, value: token); + } + + @override + Future writeRefreshToken(String? token) async { + if (token == null || token.isEmpty) { + await _storage.delete(key: _kRefreshTokenKey); + return; + } + await _storage.write(key: _kRefreshTokenKey, value: token); + } +} diff --git a/lib/core/services/token_storage_stub.dart b/lib/core/services/token_storage_stub.dart new file mode 100644 index 0000000..8a942d7 --- /dev/null +++ b/lib/core/services/token_storage_stub.dart @@ -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 clear() async => _unsupported(); + + @override + Future readAccessToken() async => _unsupported(); + + @override + Future readRefreshToken() async => _unsupported(); + + @override + Future writeAccessToken(String? token) async => _unsupported(); + + @override + Future writeRefreshToken(String? token) async => _unsupported(); +} diff --git a/lib/core/services/token_storage_web.dart b/lib/core/services/token_storage_web.dart new file mode 100644 index 0000000..a4aea39 --- /dev/null +++ b/lib/core/services/token_storage_web.dart @@ -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 _storage; + + @override + Future clear() async { + _storage.remove(_kAccessTokenKey); + _storage.remove(_kRefreshTokenKey); + } + + @override + Future readAccessToken() async => _storage[_kAccessTokenKey]; + + @override + Future readRefreshToken() async => _storage[_kRefreshTokenKey]; + + @override + Future writeAccessToken(String? token) async { + if (token == null || token.isEmpty) { + _storage.remove(_kAccessTokenKey); + } else { + _storage[_kAccessTokenKey] = token; + } + } + + @override + Future writeRefreshToken(String? token) async { + if (token == null || token.isEmpty) { + _storage.remove(_kRefreshTokenKey); + } else { + _storage[_kRefreshTokenKey] = token; + } + } +} diff --git a/lib/core/theme/superport_shad_theme.dart b/lib/core/theme/superport_shad_theme.dart new file mode 100644 index 0000000..5cca74d --- /dev/null +++ b/lib/core/theme/superport_shad_theme.dart @@ -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), + ); + } +} diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart new file mode 100644 index 0000000..1fe267b --- /dev/null +++ b/lib/core/theme/theme_controller.dart @@ -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 { + const ThemeControllerScope({ + super.key, + required ThemeController controller, + required super.child, + }) : super(notifier: controller); + + static ThemeController of(BuildContext context) { + final scope = context + .dependOnInheritedWidgetOfExactType(); + assert(scope != null, 'ThemeControllerScope가 위젯 트리에 없습니다.'); + return scope!.notifier!; + } +} diff --git a/lib/features/approvals/domain/entities/approval.dart b/lib/features/approvals/domain/entities/approval.dart index 1accb37..273bf5b 100644 --- a/lib/features/approvals/domain/entities/approval.dart +++ b/lib/features/approvals/domain/entities/approval.dart @@ -211,19 +211,13 @@ class ApprovalStepActionInput { /// 결재 단계를 일괄 등록/재배치하기 위한 입력 모델 class ApprovalStepAssignmentInput { - ApprovalStepAssignmentInput({ - required this.approvalId, - required this.steps, - }); + ApprovalStepAssignmentInput({required this.approvalId, required this.steps}); final int approvalId; final List steps; Map toPayload() { - return { - 'id': approvalId, - 'steps': steps.map((e) => e.toJson()).toList(), - }; + return {'id': approvalId, 'steps': steps.map((e) => e.toJson()).toList()}; } } diff --git a/lib/features/approvals/history/presentation/pages/approval_history_page.dart b/lib/features/approvals/history/presentation/pages/approval_history_page.dart index f5a60c7..d62aa45 100644 --- a/lib/features/approvals/history/presentation/pages/approval_history_page.dart +++ b/lib/features/approvals/history/presentation/pages/approval_history_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; +import 'package:intl/intl.dart' as intl; import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -7,6 +8,8 @@ import '../../../../../core/config/environment.dart'; import '../../../../../core/constants/app_sections.dart'; import '../../../../../widgets/app_layout.dart'; import '../../../../../widgets/components/filter_bar.dart'; +import '../../../../../widgets/components/superport_date_picker.dart'; +import '../../../../../widgets/components/superport_table.dart'; import '../../../../../widgets/spec_page.dart'; import '../../domain/entities/approval_history_record.dart'; import '../../domain/repositories/approval_history_repository.dart'; @@ -145,6 +148,19 @@ class _ApprovalHistoryEnabledPageState ), ], toolbar: FilterBar( + actions: [ + ShadButton.outline( + onPressed: _controller.isLoading ? null : _applyFilters, + child: const Text('검색 적용'), + ), + ShadButton.ghost( + onPressed: + _controller.isLoading || !_controller.hasActiveFilters + ? null + : _resetFilters, + child: const Text('필터 초기화'), + ), + ], children: [ SizedBox( width: 240, @@ -180,21 +196,24 @@ class _ApprovalHistoryEnabledPageState ), SizedBox( width: 220, - child: ShadButton.outline( - onPressed: _pickDateRange, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(lucide.LucideIcons.calendar, size: 16), - const SizedBox(width: 8), - Text( - _dateRange == null - ? '기간 선택' - : '${_formatDate(_dateRange!.start)} ~ ${_formatDate(_dateRange!.end)}', + child: SuperportDateRangePickerButton( + value: _dateRange, + dateFormat: intl.DateFormat('yyyy-MM-dd'), + enabled: !_controller.isLoading, + firstDate: DateTime(DateTime.now().year - 5), + lastDate: DateTime(DateTime.now().year + 1), + initialDateRange: + _dateRange ?? + DateTimeRange( + start: DateTime.now().subtract(const Duration(days: 7)), + end: DateTime.now(), ), - ], - ), + onChanged: (range) { + if (range == null) return; + setState(() => _dateRange = range); + _controller.updateDateRange(range.start, range.end); + _controller.fetch(page: 1); + }, ), ), if (_dateRange != null) @@ -202,17 +221,6 @@ class _ApprovalHistoryEnabledPageState onPressed: _controller.isLoading ? null : _clearDateRange, child: const Text('기간 초기화'), ), - ShadButton.outline( - onPressed: _controller.isLoading ? null : _applyFilters, - child: const Text('검색 적용'), - ), - ShadButton.ghost( - onPressed: - _controller.isLoading || !_controller.hasActiveFilters - ? null - : _resetFilters, - child: const Text('필터 초기화'), - ), ], ), child: ShadCard( @@ -283,27 +291,6 @@ class _ApprovalHistoryEnabledPageState _controller.fetch(page: 1); } - Future _pickDateRange() async { - final now = DateTime.now(); - final initial = - _dateRange ?? - DateTimeRange( - start: DateTime(now.year, now.month, now.day - 7), - end: now, - ); - final range = await showDateRangePicker( - context: context, - initialDateRange: initial, - firstDate: DateTime(now.year - 5), - lastDate: DateTime(now.year + 1), - ); - if (range != null) { - setState(() => _dateRange = range); - _controller.updateDateRange(range.start, range.end); - _controller.fetch(page: 1); - } - } - void _clearDateRange() { setState(() => _dateRange = null); _controller.updateDateRange(null, null); @@ -318,10 +305,6 @@ class _ApprovalHistoryEnabledPageState _controller.fetch(page: 1); } - String _formatDate(DateTime date) { - return DateFormat('yyyy-MM-dd').format(date.toLocal()); - } - String _actionLabel(ApprovalHistoryActionFilter filter) { switch (filter) { case ApprovalHistoryActionFilter.all: @@ -349,58 +332,60 @@ class _ApprovalHistoryTable extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = ShadTheme.of(context); final normalizedQuery = query.trim().toLowerCase(); - final header = [ - 'ID', - '결재번호', - '단계순서', - '승인자', - '행위', - '변경전 상태', - '변경후 상태', - '작업일시', - '비고', - ].map((label) => ShadTableCell.header(child: Text(label))).toList(); + + final columns = const [ + Text('ID'), + Text('결재번호'), + Text('단계순서'), + Text('승인자'), + Text('행위'), + Text('변경전 상태'), + Text('변경후 상태'), + Text('작업일시'), + Text('비고'), + ]; final rows = histories.map((history) { final isHighlighted = normalizedQuery.isNotEmpty && history.approvalNo.toLowerCase().contains(normalizedQuery); - return [ - ShadTableCell(child: Text(history.id.toString())), - ShadTableCell( - child: Text( - history.approvalNo, - style: isHighlighted - ? ShadTheme.of( - context, - ).textTheme.small.copyWith(fontWeight: FontWeight.w600) - : null, - ), - ), - ShadTableCell( - child: Text( - history.stepOrder == null ? '-' : history.stepOrder.toString(), - ), - ), - ShadTableCell(child: Text(history.approver.name)), - ShadTableCell(child: Text(history.action.name)), - ShadTableCell(child: Text(history.fromStatus?.name ?? '-')), - ShadTableCell(child: Text(history.toStatus.name)), - ShadTableCell( - child: Text(dateFormat.format(history.actionAt.toLocal())), - ), - ShadTableCell( - child: Text( - history.note?.trim().isEmpty ?? true ? '-' : history.note!, - ), + final highlightStyle = theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.foreground, + ); + final noteText = history.note?.trim(); + final noteContent = noteText?.isNotEmpty == true ? noteText : null; + final subLabelStyle = theme.textTheme.muted.copyWith( + fontSize: (theme.textTheme.muted.fontSize ?? 14) - 1, + ); + + return [ + Text(history.id.toString()), + Text(history.approvalNo, style: isHighlighted ? highlightStyle : null), + Text(history.stepOrder == null ? '-' : history.stepOrder.toString()), + Text(history.approver.name), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(history.action.name), + if (noteContent != null) Text(noteContent, style: subLabelStyle), + ], ), + Text(history.fromStatus?.name ?? '-'), + Text(history.toStatus.name), + Text(dateFormat.format(history.actionAt.toLocal())), + Text(noteContent ?? '-'), ]; }).toList(); - return ShadTable.list( - header: header, - children: rows, + return SuperportTable( + columns: columns, + rows: rows, + rowHeight: 64, + maxHeight: 520, columnSpanExtent: (index) { switch (index) { case 1: diff --git a/lib/features/approvals/step/presentation/pages/approval_step_page.dart b/lib/features/approvals/step/presentation/pages/approval_step_page.dart index 6317b35..8badb0c 100644 --- a/lib/features/approvals/step/presentation/pages/approval_step_page.dart +++ b/lib/features/approvals/step/presentation/pages/approval_step_page.dart @@ -7,6 +7,7 @@ import '../../../../../core/config/environment.dart'; import '../../../../../core/constants/app_sections.dart'; import '../../../../../widgets/app_layout.dart'; import '../../../../../widgets/components/filter_bar.dart'; +import '../../../../../widgets/components/superport_dialog.dart'; import '../../../../../widgets/spec_page.dart'; import '../controllers/approval_step_controller.dart'; import '../../domain/entities/approval_step_input.dart'; @@ -528,73 +529,50 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { if (!mounted) return; Navigator.of(context, rootNavigator: true).pop(); if (detail == null) return; - await showDialog( + final step = detail.step; + await SuperportDialog.show( context: context, - builder: (dialogContext) { - final step = detail.step; - final theme = ShadTheme.of(dialogContext); - return Dialog( - insetPadding: const EdgeInsets.all(24), - clipBehavior: Clip.antiAlias, - child: ShadCard( - title: Text('결재 단계 상세', style: theme.textTheme.h3), - description: Text( - '결재번호 ${detail.approvalNo}', - style: theme.textTheme.muted, + dialog: SuperportDialog( + title: '결재 단계 상세', + description: '결재번호 ${detail.approvalNo}', + constraints: const BoxConstraints(maxWidth: 560), + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 18, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _DetailRow(label: '단계 순서', value: '${step.stepOrder}'), + _DetailRow(label: '승인자', value: step.approver.name), + _DetailRow(label: '상태', value: step.status.name), + _DetailRow(label: '배정일시', value: _formatDate(step.assignedAt)), + _DetailRow( + label: '결정일시', + value: step.decidedAt == null + ? '-' + : _formatDate(step.decidedAt!), ), - footer: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - ShadButton.ghost( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('닫기'), - ), - ], + _DetailRow(label: '템플릿', value: detail.templateName ?? '-'), + _DetailRow(label: '트랜잭션번호', value: detail.transactionNo ?? '-'), + const SizedBox(height: 12), + Text( + '비고', + style: ShadTheme.of( + context, + ).textTheme.small.copyWith(fontWeight: FontWeight.w600), ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _DetailRow(label: '단계 순서', value: '${step.stepOrder}'), - _DetailRow(label: '승인자', value: step.approver.name), - _DetailRow(label: '상태', value: step.status.name), - _DetailRow( - label: '배정일시', - value: _formatDate(step.assignedAt), - ), - _DetailRow( - label: '결정일시', - value: step.decidedAt == null - ? '-' - : _formatDate(step.decidedAt!), - ), - _DetailRow(label: '템플릿', value: detail.templateName ?? '-'), - _DetailRow( - label: '트랜잭션번호', - value: detail.transactionNo ?? '-', - ), - const SizedBox(height: 12), - Text( - '비고', - style: theme.textTheme.small.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - ShadTextarea( - initialValue: step.note ?? '', - readOnly: true, - minHeight: 80, - maxHeight: 200, - ), - ], - ), + const SizedBox(height: 8), + ShadTextarea( + initialValue: step.note ?? '', + readOnly: true, + minHeight: 80, + maxHeight: 200, ), - ), - ); - }, + ], + ), + ), ); } @@ -724,102 +702,93 @@ class _StepFormDialogState extends State<_StepFormDialog> { final theme = ShadTheme.of(context); final materialTheme = Theme.of(context); - return Dialog( - insetPadding: const EdgeInsets.all(24), - clipBehavior: Clip.antiAlias, - child: ShadCard( - title: Text(widget.title, style: theme.textTheme.h3), - footer: Row( - mainAxisAlignment: MainAxisAlignment.end, + return SuperportDialog( + title: widget.title, + constraints: const BoxConstraints(maxWidth: 560), + primaryAction: ShadButton( + key: const ValueKey('step_form_submit'), + onPressed: _handleSubmit, + child: Text(widget.submitLabel), + ), + secondaryAction: ShadButton.ghost( + onPressed: () => Navigator.of(context).pop(), + child: const Text('취소'), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - ShadButton.ghost( - onPressed: () => Navigator.of(context).pop(), - child: const Text('취소'), - ), - const SizedBox(width: 12), - ShadButton( - key: const ValueKey('step_form_submit'), - onPressed: _handleSubmit, - child: Text(widget.submitLabel), - ), - ], - ), - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (!widget.isEditing) - _FormFieldBlock( - label: '결재 ID', - errorText: _errors['approvalId'], - child: ShadInput( - key: const ValueKey('step_form_approval_id'), - controller: _approvalIdController, - onChanged: (_) => _clearError('approvalId'), - ), - ) - else ...[ - _FormFieldBlock( - label: '결재 ID', - child: ShadInput( - controller: _approvalIdController, - readOnly: true, - ), - ), - const SizedBox(height: 16), - _FormFieldBlock( - label: '결재번호', - child: ShadInput( - controller: _approvalNoController, - readOnly: true, - ), - ), - ], - if (!widget.isEditing) const SizedBox(height: 16), + if (!widget.isEditing) _FormFieldBlock( - label: '단계 순서', - errorText: _errors['stepOrder'], + label: '결재 ID', + errorText: _errors['approvalId'], child: ShadInput( - key: const ValueKey('step_form_step_order'), - controller: _stepOrderController, - onChanged: (_) => _clearError('stepOrder'), + key: const ValueKey('step_form_approval_id'), + controller: _approvalIdController, + onChanged: (_) => _clearError('approvalId'), + ), + ) + else ...[ + _FormFieldBlock( + label: '결재 ID', + child: ShadInput( + controller: _approvalIdController, + readOnly: true, ), ), const SizedBox(height: 16), _FormFieldBlock( - label: '승인자 ID', - errorText: _errors['approverId'], + label: '결재번호', child: ShadInput( - key: const ValueKey('step_form_approver_id'), - controller: _approverIdController, - onChanged: (_) => _clearError('approverId'), + controller: _approvalNoController, + readOnly: true, ), ), - const SizedBox(height: 16), - _FormFieldBlock( - label: '비고', - helperText: '필요 시 단계에 대한 참고 내용을 남길 수 있습니다.', - child: ShadTextarea( - key: const ValueKey('step_form_note'), - controller: _noteController, - minHeight: 100, - maxHeight: 200, - ), - ), - if (_errors['form'] != null) - Padding( - padding: const EdgeInsets.only(top: 12), - child: Text( - _errors['form']!, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), ], - ), + if (!widget.isEditing) const SizedBox(height: 16), + _FormFieldBlock( + label: '단계 순서', + errorText: _errors['stepOrder'], + child: ShadInput( + key: const ValueKey('step_form_step_order'), + controller: _stepOrderController, + onChanged: (_) => _clearError('stepOrder'), + ), + ), + const SizedBox(height: 16), + _FormFieldBlock( + label: '승인자 ID', + errorText: _errors['approverId'], + child: ShadInput( + key: const ValueKey('step_form_approver_id'), + controller: _approverIdController, + onChanged: (_) => _clearError('approverId'), + ), + ), + const SizedBox(height: 16), + _FormFieldBlock( + label: '비고', + helperText: '필요 시 단계에 대한 참고 내용을 남길 수 있습니다.', + child: ShadTextarea( + key: const ValueKey('step_form_note'), + controller: _noteController, + minHeight: 100, + maxHeight: 200, + ), + ), + if (_errors['form'] != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + _errors['form']!, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + ], ), ), ); diff --git a/lib/features/approvals/template/presentation/pages/approval_template_page.dart b/lib/features/approvals/template/presentation/pages/approval_template_page.dart index 319b00d..efe2026 100644 --- a/lib/features/approvals/template/presentation/pages/approval_template_page.dart +++ b/lib/features/approvals/template/presentation/pages/approval_template_page.dart @@ -7,6 +7,7 @@ import '../../../../../core/config/environment.dart'; import '../../../../../core/constants/app_sections.dart'; import '../../../../../widgets/app_layout.dart'; import '../../../../../widgets/components/filter_bar.dart'; +import '../../../../../widgets/components/superport_table.dart'; import '../../../../../widgets/components/superport_dialog.dart'; import '../../../../../widgets/spec_page.dart'; import '../../../domain/entities/approval_template.dart'; @@ -151,6 +152,18 @@ class _ApprovalTemplateEnabledPageState ), ], toolbar: FilterBar( + actions: [ + ShadButton.outline( + onPressed: _controller.isLoading ? null : _applyFilters, + child: const Text('검색 적용'), + ), + ShadButton.ghost( + onPressed: !_controller.isLoading && showReset + ? _resetFilters + : null, + child: const Text('필터 초기화'), + ), + ], children: [ SizedBox( width: 260, @@ -183,16 +196,6 @@ class _ApprovalTemplateEnabledPageState .toList(), ), ), - ShadButton.outline( - onPressed: _controller.isLoading ? null : _applyFilters, - child: const Text('검색 적용'), - ), - ShadButton.ghost( - onPressed: !_controller.isLoading && showReset - ? _resetFilters - : null, - child: const Text('필터 초기화'), - ), ], ), child: ShadCard( @@ -213,97 +216,95 @@ class _ApprovalTemplateEnabledPageState crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - SizedBox( - height: 480, - child: ShadTable.list( - header: - ['ID', '템플릿코드', '템플릿명', '설명', '사용', '변경일시', '동작'] - .map( - (e) => ShadTableCell.header(child: Text(e)), - ) - .toList(), - columnSpanExtent: (index) { - switch (index) { - case 2: - return const FixedTableSpanExtent(220); - case 3: - return const FixedTableSpanExtent(260); - case 4: - return const FixedTableSpanExtent(100); - case 5: - return const FixedTableSpanExtent(180); - case 6: - return const FixedTableSpanExtent(160); - default: - return const FixedTableSpanExtent(140); - } - }, - 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! - : '-', - ), + SuperportTable.fromCells( + header: const [ + ShadTableCell.header(child: Text('ID')), + ShadTableCell.header(child: Text('템플릿코드')), + ShadTableCell.header(child: Text('템플릿명')), + ShadTableCell.header(child: Text('설명')), + ShadTableCell.header(child: Text('사용')), + ShadTableCell.header(child: Text('변경일시')), + ShadTableCell.header(child: Text('동작')), + ], + rows: templates.map((template) { + return [ + ShadTableCell(child: Text('${template.id}')), + ShadTableCell(child: Text(template.code)), + ShadTableCell(child: Text(template.name)), + ShadTableCell( + child: Text( + template.description?.isNotEmpty == true + ? template.description! + : '-', ), - ShadTableCell( - child: template.isActive - ? const ShadBadge(child: Text('사용')) - : const ShadBadge.outline( - child: Text('미사용'), + ), + ShadTableCell( + child: template.isActive + ? const ShadBadge(child: Text('사용')) + : const ShadBadge.outline(child: Text('미사용')), + ), + ShadTableCell( + child: Text( + template.updatedAt == null + ? '-' + : _dateFormat.format( + template.updatedAt!.toLocal(), ), ), - 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('수정'), + ), + ShadTableCell( + alignment: Alignment.centerRight, + child: Wrap( + spacing: 8, + children: [ + ShadButton.ghost( + key: ValueKey( + 'template_edit_${template.id}', ), - 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('복구'), - ), - ], - ), + 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('삭제'), + ) + : ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isSubmitting + ? null + : () => _confirmRestore(template), + child: const Text('복구'), + ), + ], ), - ]; - }).toList(), - ), + ), + ]; + }).toList(), + rowHeight: 56, + maxHeight: 480, + columnSpanExtent: (index) { + switch (index) { + case 2: + return const FixedTableSpanExtent(220); + case 3: + return const FixedTableSpanExtent(260); + case 4: + return const FixedTableSpanExtent(100); + case 5: + return const FixedTableSpanExtent(180); + case 6: + return const FixedTableSpanExtent(160); + default: + return const FixedTableSpanExtent(140); + } + }, ), const SizedBox(height: 16), Row( @@ -382,26 +383,23 @@ class _ApprovalTemplateEnabledPageState } Future _confirmDelete(ApprovalTemplate template) async { - final confirmed = await showDialog( + final confirmed = await SuperportDialog.show( context: context, - builder: (dialogContext) { - return AlertDialog( - title: const Text('템플릿 삭제'), - content: Text( + dialog: SuperportDialog( + title: '템플릿 삭제', + description: '"${template.name}" 템플릿을 삭제하시겠습니까?\n삭제 시 템플릿은 미사용 상태로 전환됩니다.', + actions: [ + ShadButton.ghost( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('취소'), ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: const Text('취소'), - ), - FilledButton.tonal( - onPressed: () => Navigator.of(dialogContext).pop(true), - child: const Text('삭제'), - ), - ], - ); - }, + ShadButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('삭제'), + ), + ], + ), ); if (confirmed != true) return; final ok = await _controller.delete(template.id); @@ -412,24 +410,22 @@ class _ApprovalTemplateEnabledPageState } Future _confirmRestore(ApprovalTemplate template) async { - final confirmed = await showDialog( + final confirmed = await SuperportDialog.show( context: context, - builder: (dialogContext) { - return AlertDialog( - title: const Text('템플릿 복구'), - content: Text('"${template.name}" 템플릿을 복구하시겠습니까?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: const Text('취소'), - ), - FilledButton( - onPressed: () => Navigator.of(dialogContext).pop(true), - child: const Text('복구'), - ), - ], - ); - }, + dialog: SuperportDialog( + title: '템플릿 복구', + description: '"${template.name}" 템플릿을 복구하시겠습니까?', + actions: [ + ShadButton.ghost( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('취소'), + ), + ShadButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('복구'), + ), + ], + ), ); if (confirmed != true) return; final restored = await _controller.restore(template.id); @@ -454,10 +450,74 @@ class _ApprovalTemplateEnabledPageState String? errorText; StateSetter? modalSetState; + Future 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( context: context, title: isEdit ? '템플릿 수정' : '템플릿 생성', barrierDismissible: !isSaving, + onSubmit: handleSubmit, body: StatefulBuilder( builder: (dialogContext, setModalState) { modalSetState = setModalState; @@ -594,68 +654,7 @@ class _ApprovalTemplateEnabledPageState child: const Text('취소'), ), ShadButton( - onPressed: () async { - if (isSaving) return; - final codeValue = codeController.text.trim(); - final nameValue = nameController.text.trim(); - if (!isEdit && codeValue.isEmpty) { - modalSetState?.call(() => errorText = '템플릿 코드를 입력하세요.'); - return; - } - if (nameValue.isEmpty) { - modalSetState?.call(() => errorText = '템플릿명을 입력하세요.'); - return; - } - final validation = _validateSteps(steps); - if (validation != null) { - modalSetState?.call(() => errorText = validation); - return; - } - modalSetState?.call(() => errorText = null); - final stepInputs = steps - .map( - (field) => ApprovalTemplateStepInput( - id: field.id, - stepOrder: int.parse(field.orderController.text.trim()), - approverId: int.parse(field.approverController.text.trim()), - note: field.noteController.text.trim().isEmpty - ? null - : field.noteController.text.trim(), - ), - ) - .toList(); - final input = ApprovalTemplateInput( - code: isEdit ? existingTemplate?.code : codeValue, - name: nameValue, - description: descriptionController.text.trim().isEmpty - ? null - : descriptionController.text.trim(), - note: noteController.text.trim().isEmpty - ? null - : noteController.text.trim(), - isActive: statusNotifier.value, - ); - if (isEdit && existingTemplate == null) { - modalSetState?.call(() => errorText = '템플릿 정보를 불러오지 못했습니다.'); - modalSetState?.call(() => isSaving = false); - return; - } - - modalSetState?.call(() => isSaving = true); - - final success = isEdit && existingTemplate != null - ? await _controller.update( - existingTemplate.id, - input, - stepInputs, - ) - : await _controller.create(input, stepInputs); - if (success != null && mounted) { - Navigator.of(context).pop(true); - } else { - modalSetState?.call(() => isSaving = false); - } - }, + onPressed: handleSubmit, child: Text(isEdit ? '수정 완료' : '생성 완료'), ), ], diff --git a/lib/features/dashboard/presentation/pages/dashboard_page.dart b/lib/features/dashboard/presentation/pages/dashboard_page.dart index 775bff3..e613249 100644 --- a/lib/features/dashboard/presentation/pages/dashboard_page.dart +++ b/lib/features/dashboard/presentation/pages/dashboard_page.dart @@ -1,24 +1,313 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; +import 'package:shadcn_ui/shadcn_ui.dart'; -import '../../../../widgets/spec_page.dart'; +import 'package:superport_v2/widgets/app_layout.dart'; +import 'package:superport_v2/widgets/components/empty_state.dart'; class DashboardPage extends StatelessWidget { const DashboardPage({super.key}); + static const _recentTransactions = [ + ('IN-20240312-003', '2024-03-12', '입고', '승인완료', '김담당'), + ('OUT-20240311-005', '2024-03-11', '출고', '출고대기', '이물류'), + ('RENT-20240310-001', '2024-03-10', '대여', '대여중', '박대여'), + ('APP-20240309-004', '2024-03-09', '결재', '진행중', '최결재'), + ]; + + static const _pendingApprovals = [ + ('APP-20240312-010', '설비 구매', '2/4 단계 진행 중'), + ('APP-20240311-004', '창고 정기 점검', '승인 대기'), + ('APP-20240309-002', '계약 연장', '반려 후 재상신'), + ]; + @override Widget build(BuildContext context) { - return const SpecPage( + return AppLayout( title: '대시보드', - summary: '오늘 입고/출고, 결재 대기, 최근 트랜잭션을 한 눈에 볼 수 있는 메인 화면 구성.', - sections: [ - SpecSection( - title: '주요 위젯', - items: [ - '오늘 입고/출고 건수, 대기 결재 수 KPI 카드', - '최근 트랜잭션 리스트: 번호 · 일자 · 유형 · 상태 · 작성자', - '내 결재 요청/대기 건 알림 패널', + subtitle: '입·출·대여 현황과 결재 대기를 한 눈에 확인합니다.', + breadcrumbs: const [AppBreadcrumbItem(label: '대시보드')], + child: SingleChildScrollView( + padding: const EdgeInsets.only(right: 12, bottom: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Wrap( + spacing: 16, + runSpacing: 16, + children: const [ + _KpiCard( + icon: lucide.LucideIcons.packagePlus, + label: '오늘 입고', + value: '12건', + trend: '+3 vs 어제', + ), + _KpiCard( + icon: lucide.LucideIcons.packageMinus, + label: '오늘 출고', + value: '9건', + trend: '-2 vs 어제', + ), + _KpiCard( + icon: lucide.LucideIcons.messageSquareWarning, + label: '결재 대기', + value: '5건', + trend: '평균 12시간 지연', + ), + _KpiCard( + icon: lucide.LucideIcons.users, + label: '고객사 문의', + value: '7건', + trend: '지원팀 확인 중', + ), + ], + ), + const SizedBox(height: 24), + LayoutBuilder( + builder: (context, constraints) { + final showSidePanel = constraints.maxWidth > 920; + return Flex( + direction: showSidePanel ? Axis.horizontal : Axis.vertical, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: _RecentTransactionsCard( + transactions: _recentTransactions, + ), + ), + if (showSidePanel) + const SizedBox(width: 16) + else + const SizedBox(height: 16), + Flexible( + flex: 2, + child: _PendingApprovalCard(approvals: _pendingApprovals), + ), + ], + ); + }, + ), + const SizedBox(height: 24), + const _ReminderPanel(), ], ), + ), + ); + } +} + +class _KpiCard extends StatelessWidget { + const _KpiCard({ + required this.icon, + required this.label, + required this.value, + required this.trend, + }); + + final IconData icon; + final String label; + final String value; + final String trend; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return ConstrainedBox( + constraints: const BoxConstraints(minWidth: 220, maxWidth: 260), + child: ShadCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 20, color: theme.colorScheme.primary), + const SizedBox(height: 12), + Text(label, style: theme.textTheme.small), + const SizedBox(height: 6), + Text(value, style: theme.textTheme.h3), + const SizedBox(height: 8), + Text(trend, style: theme.textTheme.muted), + ], + ), + ), + ); + } +} + +class _RecentTransactionsCard extends StatelessWidget { + const _RecentTransactionsCard({required this.transactions}); + + final List<(String, String, String, String, String)> transactions; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return ShadCard( + title: Text('최근 트랜잭션', style: theme.textTheme.h3), + description: Text( + '최근 7일간의 입·출고 및 대여/결재 흐름입니다.', + style: theme.textTheme.muted, + ), + child: SizedBox( + height: 320, + child: ShadTable.list( + header: const [ + ShadTableCell.header(child: Text('번호')), + ShadTableCell.header(child: Text('일자')), + ShadTableCell.header(child: Text('유형')), + ShadTableCell.header(child: Text('상태')), + ShadTableCell.header(child: Text('작성자')), + ], + children: [ + for (final row in transactions) + [ + ShadTableCell(child: Text(row.$1)), + ShadTableCell(child: Text(row.$2)), + ShadTableCell(child: Text(row.$3)), + ShadTableCell(child: Text(row.$4)), + ShadTableCell(child: Text(row.$5)), + ], + ], + columnSpanExtent: (index) => const FixedTableSpanExtent(140), + rowSpanExtent: (index) => const FixedTableSpanExtent(52), + ), + ), + ); + } +} + +class _PendingApprovalCard extends StatelessWidget { + const _PendingApprovalCard({required this.approvals}); + + final List<(String, String, String)> approvals; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + if (approvals.isEmpty) { + return ShadCard( + title: Text('내 결재 대기', style: theme.textTheme.h3), + description: Text( + '현재 승인 대기 중인 결재 요청입니다.', + style: theme.textTheme.muted, + ), + child: const SuperportEmptyState( + title: '대기 중인 결재가 없습니다', + description: '새로운 결재 요청이 등록되면 이곳에서 바로 확인할 수 있습니다.', + ), + ); + } + + return ShadCard( + title: Text('내 결재 대기', style: theme.textTheme.h3), + description: Text('현재 승인 대기 중인 결재 요청입니다.', style: theme.textTheme.muted), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final approval in approvals) + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + lucide.LucideIcons.bell, + size: 18, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(approval.$1, style: theme.textTheme.small), + const SizedBox(height: 4), + Text(approval.$2, style: theme.textTheme.h4), + const SizedBox(height: 4), + Text(approval.$3, style: theme.textTheme.muted), + ], + ), + ), + ShadButton.ghost( + size: ShadButtonSize.sm, + child: const Text('상세'), + onPressed: () {}, + ), + ], + ), + ), + ], + ), + ); + } +} + +class _ReminderPanel extends StatelessWidget { + const _ReminderPanel(); + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return ShadCard( + title: Text('주의/알림', style: theme.textTheme.h3), + description: Text( + '지연된 결재나 시스템 점검 일정을 확인하세요.', + style: theme.textTheme.muted, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: const [ + _ReminderItem( + icon: lucide.LucideIcons.clock, + label: '결재 지연', + message: '영업부 장비 구매 결재가 2일째 대기 중입니다.', + ), + SizedBox(height: 12), + _ReminderItem( + icon: lucide.LucideIcons.triangleAlert, + label: '시스템 점검', + message: '2024-03-15 22:00 ~ 23:00 서버 점검이 예정되어 있습니다.', + ), + SizedBox(height: 12), + _ReminderItem( + icon: lucide.LucideIcons.mail, + label: '고객 문의', + message: '3건의 신규 고객 문의가 접수되었습니다.', + ), + ], + ), + ); + } +} + +class _ReminderItem extends StatelessWidget { + const _ReminderItem({ + required this.icon, + required this.label, + required this.message, + }); + + final IconData icon; + final String label; + final String message; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 18, color: theme.colorScheme.secondary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.small), + const SizedBox(height: 4), + Text(message, style: theme.textTheme.p), + ], + ), + ), ], ); } diff --git a/lib/features/inventory/shared/catalogs.dart b/lib/features/inventory/shared/catalogs.dart new file mode 100644 index 0000000..676d0b4 --- /dev/null +++ b/lib/features/inventory/shared/catalogs.dart @@ -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 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 _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 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 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 _byName = { + for (final item in items) item.name: item, + }; + + static InventoryCustomerCatalogItem? byName(String name) => _byName[name]; + + static List 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), + ); +} diff --git a/lib/features/inventory/shared/widgets/product_autocomplete_field.dart b/lib/features/inventory/shared/widgets/product_autocomplete_field.dart new file mode 100644 index 0000000..bcb5fa0 --- /dev/null +++ b/lib/features/inventory/shared/widgets/product_autocomplete_field.dart @@ -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 onCatalogMatched; + final VoidCallback? onChanged; + + @override + State createState() => + _InventoryProductAutocompleteFieldState(); +} + +class _InventoryProductAutocompleteFieldState + extends State { + 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 _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( + 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, + ), + ), + ], + ), + ), + ); + }, + ), + ), + ), + ); + }, + ); + }, + ); + } +} diff --git a/lib/features/inventory/transactions/data/dtos/stock_transaction_dto.dart b/lib/features/inventory/transactions/data/dtos/stock_transaction_dto.dart new file mode 100644 index 0000000..b43f6bb --- /dev/null +++ b/lib/features/inventory/transactions/data/dtos/stock_transaction_dto.dart @@ -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 lines; + final List customers; + final StockTransactionApprovalSummary? approval; + final DateTime? expectedReturnDate; + + /// JSON 객체를 DTO로 변환한다. + factory StockTransactionDto.fromJson(Map json) { + final typeJson = json['transaction_type'] as Map?; + final statusJson = json['transaction_status'] as Map?; + final warehouseJson = json['warehouse'] as Map?; + final createdByJson = json['created_by'] as Map?; + + 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 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 ? json : {}; + return PaginatedResult( + 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? json) { + return StockTransactionType( + id: json?['id'] as int? ?? 0, + name: json?['type_name'] as String? ?? '', + ); +} + +StockTransactionStatus _parseStatus(Map? json) { + return StockTransactionStatus( + id: json?['id'] as int? ?? 0, + name: json?['status_name'] as String? ?? '', + ); +} + +StockTransactionWarehouse _parseWarehouse(Map? json) { + final zipcode = json?['zipcode'] as Map?; + 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? json) { + return StockTransactionEmployee( + id: json?['id'] as int? ?? 0, + employeeNo: json?['employee_no'] as String? ?? '', + name: json?['employee_name'] as String? ?? '', + ); +} + +List _parseLines(Map 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?), + quantity: JsonUtils.readInt(item, 'quantity', fallback: 0), + unitPrice: _readDouble(item['unit_price']), + note: item['note'] as String?, + ), + ]; +} + +StockTransactionProduct _parseProduct(Map? json) { + final vendorJson = json?['vendor'] as Map?; + final uomJson = json?['uom'] as Map?; + 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 _parseCustomers(Map 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?), + note: item['note'] as String?, + ), + ]; +} + +StockTransactionCustomerSummary _parseCustomer(Map? 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) { + return null; + } + final status = raw['approval_status'] as Map?; + 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; +} diff --git a/lib/features/inventory/transactions/domain/entities/stock_transaction.dart b/lib/features/inventory/transactions/domain/entities/stock_transaction.dart new file mode 100644 index 0000000..ef60cc3 --- /dev/null +++ b/lib/features/inventory/transactions/domain/entities/stock_transaction.dart @@ -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 lines; + final List customers; + final StockTransactionApprovalSummary? approval; + final DateTime? expectedReturnDate; + + int get itemCount => lines.length; + + int get totalQuantity => lines.fold( + 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? lines, + List? 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 { + /// 라인 품목 가격 총액을 계산한다. + double get totalAmount => + fold(0, (sum, line) => sum + (line.quantity * line.unitPrice)); +} diff --git a/lib/features/login/presentation/pages/login_page.dart b/lib/features/login/presentation/pages/login_page.dart index 15799b1..470a69a 100644 --- a/lib/features/login/presentation/pages/login_page.dart +++ b/lib/features/login/presentation/pages/login_page.dart @@ -15,6 +15,8 @@ class _LoginPageState extends State { final idController = TextEditingController(); final passwordController = TextEditingController(); bool rememberMe = false; + bool isLoading = false; + String? errorMessage; @override void dispose() { @@ -23,7 +25,35 @@ class _LoginPageState extends State { super.dispose(); } - void _handleSubmit() { + Future _handleSubmit() async { + if (isLoading) return; + setState(() { + errorMessage = null; + isLoading = true; + }); + + final id = idController.text.trim(); + final password = passwordController.text.trim(); + + await Future.delayed(const Duration(milliseconds: 600)); + + if (id.isEmpty || password.isEmpty) { + setState(() { + errorMessage = '아이디와 비밀번호를 모두 입력하세요.'; + isLoading = false; + }); + return; + } + + if (password.length < 6) { + setState(() { + errorMessage = '비밀번호는 6자 이상이어야 합니다.'; + isLoading = false; + }); + return; + } + + if (!mounted) return; context.go(dashboardRoutePath); } @@ -73,9 +103,33 @@ class _LoginPageState extends State { ], ), const SizedBox(height: 24), + if (errorMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + errorMessage!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ), ShadButton( - onPressed: _handleSubmit, - child: const Text('로그인'), + onPressed: isLoading ? null : _handleSubmit, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (isLoading) ...[ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 8), + ], + const Text('로그인'), + ], + ), ), ], ), diff --git a/lib/features/masters/customer/presentation/pages/customer_page.dart b/lib/features/masters/customer/presentation/pages/customer_page.dart index 881adff..7d1e322 100644 --- a/lib/features/masters/customer/presentation/pages/customer_page.dart +++ b/lib/features/masters/customer/presentation/pages/customer_page.dart @@ -6,6 +6,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; +import 'package:superport_v2/widgets/components/superport_dialog.dart'; import 'package:superport_v2/features/util/postal_search/presentation/models/postal_search_result.dart'; import 'package:superport_v2/features/util/postal_search/presentation/widgets/postal_search_dialog.dart'; @@ -198,6 +199,33 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { ), ], toolbar: FilterBar( + actions: [ + ShadButton.outline( + onPressed: _controller.isLoading ? null : _applyFilters, + child: const Text('검색 적용'), + ), + if (showReset) + ShadButton.ghost( + onPressed: _controller.isLoading + ? null + : () { + _searchController.clear(); + _searchFocus.requestFocus(); + _controller.updateQuery(''); + _controller.updateTypeFilter(CustomerTypeFilter.all); + _controller.updateStatusFilter( + CustomerStatusFilter.all, + ); + _updateRoute( + page: 1, + queryOverride: '', + typeOverride: CustomerTypeFilter.all, + statusOverride: CustomerStatusFilter.all, + ); + }, + child: const Text('초기화'), + ), + ], children: [ SizedBox( width: 260, @@ -251,31 +279,6 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { .toList(), ), ), - ShadButton.outline( - onPressed: _controller.isLoading ? null : _applyFilters, - child: const Text('검색 적용'), - ), - if (showReset) - ShadButton.ghost( - onPressed: _controller.isLoading - ? null - : () { - _searchController.clear(); - _searchFocus.requestFocus(); - _controller.updateQuery(''); - _controller.updateTypeFilter(CustomerTypeFilter.all); - _controller.updateStatusFilter( - CustomerStatusFilter.all, - ); - _updateRoute( - page: 1, - queryOverride: '', - typeOverride: CustomerTypeFilter.all, - statusOverride: CustomerStatusFilter.all, - ); - }, - child: const Text('초기화'), - ), ], ), child: ShadCard( @@ -515,395 +518,427 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { final codeError = ValueNotifier(null); final nameError = ValueNotifier(null); final typeError = ValueNotifier(null); + final zipcodeError = ValueNotifier(null); - await showDialog( - context: parentContext, - builder: (dialogContext) { - final theme = ShadTheme.of(dialogContext); - final materialTheme = Theme.of(dialogContext); - final navigator = Navigator.of(dialogContext); - Future openPostalSearch() async { - final keyword = zipcodeController.text.trim(); - final result = await showPostalSearchDialog( - dialogContext, - initialKeyword: keyword.isEmpty ? null : keyword, - ); - if (result == null) { - return; - } - zipcodeController - ..text = result.zipcode - ..selection = TextSelection.collapsed( - offset: result.zipcode.length, - ); - selectedPostalNotifier.value = result; - if (result.fullAddress.isNotEmpty) { - addressController - ..text = result.fullAddress - ..selection = TextSelection.collapsed( - offset: addressController.text.length, - ); - } + 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; + } + } - 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( - valueListenable: saving, - builder: (_, isSaving, __) { - return Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - ShadButton.ghost( - onPressed: isSaving ? null : () => navigator.pop(false), - child: const Text('취소'), - ), - const SizedBox(width: 12), - ShadButton( - onPressed: isSaving - ? null - : () async { - final code = codeController.text.trim(); - final name = nameController.text.trim(); - final email = emailController.text.trim(); - final mobile = mobileController.text.trim(); - final zipcode = zipcodeController.text.trim(); - final address = addressController.text.trim(); - final note = noteController.text.trim(); - final partner = partnerNotifier.value; - var general = generalNotifier.value; + void handlePostalSelectionChange() { + if (selectedPostalNotifier.value != null) { + zipcodeError.value = null; + } + } - codeError.value = code.isEmpty - ? '고객사코드를 입력하세요.' - : null; - nameError.value = name.isEmpty - ? '고객사명을 입력하세요.' - : null; + zipcodeController.addListener(handleZipcodeChange); + selectedPostalNotifier.addListener(handlePostalSelectionChange); - if (!partner && !general) { - general = true; - generalNotifier.value = true; - } + Future openPostalSearch(BuildContext dialogContext) async { + final keyword = zipcodeController.text.trim(); + final result = await showPostalSearchDialog( + dialogContext, + initialKeyword: keyword.isEmpty ? null : keyword, + ); + if (result == null) { + return; + } + isApplyingPostalSelection = true; + zipcodeController + ..text = result.zipcode + ..selection = TextSelection.collapsed(offset: result.zipcode.length); + isApplyingPostalSelection = false; + selectedPostalNotifier.value = result; + if (result.fullAddress.isNotEmpty) { + addressController + ..text = result.fullAddress + ..selection = TextSelection.collapsed( + offset: addressController.text.length, + ); + } + } - typeError.value = (!partner && !general) - ? '파트너/일반 중 하나 이상 선택하세요.' - : null; + await SuperportDialog.show( + context: parentContext, + dialog: SuperportDialog( + title: isEdit ? '고객사 수정' : '고객사 등록', + description: '고객사 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.', + primaryAction: ValueListenableBuilder( + valueListenable: saving, + builder: (context, isSaving, _) { + return ShadButton( + onPressed: isSaving + ? null + : () async { + final code = codeController.text.trim(); + final name = nameController.text.trim(); + final email = emailController.text.trim(); + final mobile = mobileController.text.trim(); + final zipcode = zipcodeController.text.trim(); + final address = addressController.text.trim(); + final note = noteController.text.trim(); + final partner = partnerNotifier.value; + var general = generalNotifier.value; + final selectedPostal = selectedPostalNotifier.value; - if (codeError.value != null || - nameError.value != null || - typeError.value != null) { - return; - } + codeError.value = code.isEmpty ? '고객사코드를 입력하세요.' : null; + nameError.value = name.isEmpty ? '고객사명을 입력하세요.' : null; + zipcodeError.value = + zipcode.isNotEmpty && selectedPostal == null + ? '우편번호 검색으로 주소를 선택하세요.' + : null; - saving.value = true; - final input = CustomerInput( - customerCode: code, - customerName: name, - isPartner: partner, - isGeneral: general, - email: email.isEmpty ? null : email, - mobileNo: mobile.isEmpty ? null : mobile, - zipcode: zipcode.isEmpty ? null : zipcode, - addressDetail: address.isEmpty - ? null - : address, - isActive: isActiveNotifier.value, - note: note.isEmpty ? null : note, - ); - final response = isEdit - ? await _controller.update( - customerId!, - input, - ) - : await _controller.create(input); - saving.value = false; - if (response != null) { - if (!navigator.mounted) { - return; - } - if (mounted) { - _showSnack( - isEdit ? '고객사를 수정했습니다.' : '고객사를 등록했습니다.', - ); - } - navigator.pop(true); + if (!partner && !general) { + general = true; + generalNotifier.value = true; + } + + typeError.value = (!partner && !general) + ? '파트너/일반 중 하나 이상 선택하세요.' + : null; + + if (codeError.value != null || + nameError.value != null || + zipcodeError.value != null || + typeError.value != null) { + return; + } + + saving.value = true; + final input = CustomerInput( + customerCode: code, + customerName: name, + isPartner: partner, + isGeneral: general, + email: email.isEmpty ? null : email, + mobileNo: mobile.isEmpty ? null : mobile, + 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(customerId!, input) + : await _controller.create(input); + saving.value = false; + if (response != null) { + if (!navigator.mounted) { + return; + } + if (mounted) { + _showSnack(isEdit ? '고객사를 수정했습니다.' : '고객사를 등록했습니다.'); + } + navigator.pop(true); + } + }, + child: Text(isEdit ? '저장' : '등록'), + ); + }, + ), + secondaryAction: ValueListenableBuilder( + valueListenable: saving, + builder: (context, isSaving, _) { + return ShadButton.ghost( + onPressed: isSaving + ? null + : () => Navigator.of(context).pop(false), + child: const Text('취소'), + ); + }, + ), + child: ValueListenableBuilder( + valueListenable: saving, + builder: (context, isSaving, _) { + final theme = ShadTheme.of(context); + final materialTheme = Theme.of(context); + + return SingleChildScrollView( + padding: const EdgeInsets.only(right: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ValueListenableBuilder( + valueListenable: codeError, + builder: (_, errorText, __) { + return _FormField( + label: '고객사코드', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: codeController, + readOnly: isEdit, + onChanged: (_) { + if (codeController.text.trim().isNotEmpty) { + codeError.value = null; } }, - child: Text(isEdit ? '저장' : '등록'), - ), - ], - ); - }, - ), - child: SingleChildScrollView( - padding: const EdgeInsets.only(right: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ValueListenableBuilder( - valueListenable: codeError, - builder: (_, errorText, __) { - return _FormField( - label: '고객사코드', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - controller: codeController, - readOnly: isEdit, - onChanged: (_) { - if (codeController.text.trim().isNotEmpty) { - codeError.value = null; - } - }, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, ), ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: nameError, - builder: (_, errorText, __) { - return _FormField( - label: '고객사명', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - controller: nameController, - onChanged: (_) { - if (nameController.text.trim().isNotEmpty) { - nameError.value = null; - } - }, ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: nameError, + builder: (_, errorText, __) { + return _FormField( + label: '고객사명', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: nameController, + onChanged: (_) { + if (nameController.text.trim().isNotEmpty) { + nameError.value = null; + } + }, + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, ), ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: partnerNotifier, - builder: (_, partner, __) { - return ValueListenableBuilder( - valueListenable: generalNotifier, - builder: (_, general, __) { - return ValueListenableBuilder( - valueListenable: typeError, - builder: (_, errorText, __) { - final onChanged = saving.value - ? null - : (bool? value) { - if (value == null) return; - partnerNotifier.value = value; - if (!value && !generalNotifier.value) { - typeError.value = - '파트너/일반 중 하나 이상 선택하세요.'; - } else { - typeError.value = null; - } - }; - final onChangedGeneral = saving.value - ? null - : (bool? value) { - if (value == null) return; - generalNotifier.value = value; - if (!value && !partnerNotifier.value) { - typeError.value = - '파트너/일반 중 하나 이상 선택하세요.'; - } else { - typeError.value = null; - } - }; - return _FormField( - label: '유형', - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Row( - children: [ - ShadCheckbox( - value: partner, - onChanged: onChanged, - ), - const SizedBox(width: 8), - const Text('파트너'), - const SizedBox(width: 24), - ShadCheckbox( - value: general, - onChanged: onChangedGeneral, - ), - const SizedBox(width: 8), - const Text('일반'), - ], - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only( - top: 6, - ), - child: Text( - errorText, - style: theme.textTheme.small - .copyWith( - color: materialTheme - .colorScheme - .error, - ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: partnerNotifier, + builder: (_, partner, __) { + return ValueListenableBuilder( + valueListenable: generalNotifier, + builder: (_, general, __) { + return ValueListenableBuilder( + valueListenable: typeError, + builder: (_, errorText, __) { + final onChanged = isSaving + ? null + : (bool? value) { + if (value == null) return; + partnerNotifier.value = value; + if (!value && !generalNotifier.value) { + typeError.value = + '파트너/일반 중 하나 이상 선택하세요.'; + } else { + typeError.value = null; + } + }; + final onChangedGeneral = isSaving + ? null + : (bool? value) { + if (value == null) return; + generalNotifier.value = value; + if (!value && !partnerNotifier.value) { + typeError.value = + '파트너/일반 중 하나 이상 선택하세요.'; + } else { + typeError.value = null; + } + }; + return _FormField( + label: '유형', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + ShadCheckbox( + value: partner, + onChanged: onChanged, + ), + const SizedBox(width: 8), + const Text('파트너'), + const SizedBox(width: 24), + ShadCheckbox( + value: general, + onChanged: onChangedGeneral, + ), + const SizedBox(width: 8), + const Text('일반'), + ], + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: + materialTheme.colorScheme.error, ), ), - ], - ), - ); - }, - ); - }, - ); - }, - ), - const SizedBox(height: 16), - _FormField( - label: '이메일', - child: ShadInput( - controller: emailController, - keyboardType: TextInputType.emailAddress, - ), - ), - const SizedBox(height: 16), - _FormField( - label: '연락처', - child: ShadInput( - controller: mobileController, - keyboardType: TextInputType.phone, - ), - ), - const SizedBox(height: 16), - _FormField( - label: '우편번호', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: ShadInput( - controller: zipcodeController, - placeholder: const Text('예: 06000'), - keyboardType: TextInputType.number, + ), + ], ), - ), - const SizedBox(width: 8), - ShadButton.outline( - onPressed: saving.value - ? null - : openPostalSearch, - child: const Text('검색'), - ), - ], - ), - const SizedBox(height: 8), - ValueListenableBuilder( - valueListenable: selectedPostalNotifier, - builder: (_, selection, __) { - if (selection == null) { - return Text( - '검색 버튼을 눌러 주소를 선택하세요.', - style: theme.textTheme.small.copyWith( - color: theme.colorScheme.mutedForeground, - ), - ); - } - final fullAddress = selection.fullAddress; - return Text( - fullAddress.isEmpty - ? '선택한 우편번호에 주소 정보가 없습니다.' - : fullAddress, - style: theme.textTheme.small, ); }, - ), - ], - ), + ); + }, + ); + }, + ), + const SizedBox(height: 16), + _FormField( + label: '이메일', + child: ShadInput( + controller: emailController, + keyboardType: TextInputType.emailAddress, ), - const SizedBox(height: 16), - _FormField( - label: '상세주소', - child: ShadInput( - controller: addressController, - placeholder: const Text('상세주소 입력'), - ), + ), + const SizedBox(height: 16), + _FormField( + label: '연락처', + child: ShadInput( + controller: mobileController, + keyboardType: TextInputType.phone, ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: isActiveNotifier, - builder: (_, value, __) { - return _FormField( - label: '사용여부', - child: Row( - children: [ - ShadSwitch( - value: value, - onChanged: saving.value - ? null - : (next) => isActiveNotifier.value = next, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: zipcodeError, + builder: (_, zipcodeErrorText, __) { + return _FormField( + label: '우편번호', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: ShadInput( + controller: zipcodeController, + placeholder: const Text('예: 06000'), + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 8), + ShadButton.outline( + onPressed: isSaving + ? null + : () => openPostalSearch(context), + child: const Text('검색'), + ), + ], + ), + const SizedBox(height: 8), + ValueListenableBuilder( + valueListenable: selectedPostalNotifier, + builder: (_, selection, __) { + if (selection == null) { + return Text( + '검색 버튼을 눌러 주소를 선택하세요.', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ); + } + final fullAddress = selection.fullAddress; + return Text( + fullAddress.isEmpty + ? '선택한 우편번호에 주소 정보가 없습니다.' + : fullAddress, + style: theme.textTheme.small, + ); + }, + ), + if (zipcodeErrorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + zipcodeErrorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), ), - const SizedBox(width: 8), - Text(value ? '사용' : '미사용'), - ], - ), - ); - }, + ], + ), + ); + }, + ), + const SizedBox(height: 16), + _FormField( + label: '상세주소', + child: ShadInput( + controller: addressController, + placeholder: const Text('상세주소 입력'), ), - const SizedBox(height: 16), - _FormField( - label: '비고', - child: ShadTextarea(controller: noteController), - ), - if (existing != null) ..._buildAuditInfo(existing, theme), - ], - ), + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: isActiveNotifier, + builder: (_, value, __) { + return _FormField( + label: '사용여부', + child: Row( + children: [ + ShadSwitch( + value: value, + onChanged: isSaving + ? null + : (next) => isActiveNotifier.value = next, + ), + const SizedBox(width: 8), + Text(value ? '사용' : '미사용'), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + _FormField( + label: '비고', + child: ShadTextarea(controller: noteController), + ), + if (existing != null) ..._buildAuditInfo(existing, theme), + ], ), - ), - ), - ); - }, + ); + }, + ), + ), ); + zipcodeController.removeListener(handleZipcodeChange); + selectedPostalNotifier.removeListener(handlePostalSelectionChange); codeController.dispose(); nameController.dispose(); @@ -920,27 +955,26 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { codeError.dispose(); nameError.dispose(); typeError.dispose(); + zipcodeError.dispose(); } Future _confirmDelete(Customer customer) async { - final confirmed = await showDialog( + final confirmed = await SuperportDialog.show( context: context, - builder: (dialogContext) { - return AlertDialog( - title: const Text('고객사 삭제'), - content: Text('"${customer.customerName}" 고객사를 삭제하시겠습니까?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: const Text('취소'), - ), - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(true), - child: const Text('삭제'), - ), - ], - ); - }, + dialog: SuperportDialog( + title: '고객사 삭제', + description: '"${customer.customerName}" 고객사를 삭제하시겠습니까?', + actions: [ + ShadButton.ghost( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('취소'), + ), + ShadButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('삭제'), + ), + ], + ), ); if (confirmed == true && customer.id != null) { diff --git a/lib/features/masters/group/presentation/pages/group_page.dart b/lib/features/masters/group/presentation/pages/group_page.dart index 2c2bd22..a559826 100644 --- a/lib/features/masters/group/presentation/pages/group_page.dart +++ b/lib/features/masters/group/presentation/pages/group_page.dart @@ -5,6 +5,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; +import 'package:superport_v2/widgets/components/superport_dialog.dart'; import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; @@ -130,7 +131,8 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> { ? false : (result.page * result.pageSize) < result.total; - final showReset = _searchController.text.isNotEmpty || + final showReset = + _searchController.text.isNotEmpty || _controller.defaultFilter != GroupDefaultFilter.all || _controller.statusFilter != GroupStatusFilter.all; @@ -145,12 +147,35 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> { actions: [ ShadButton( leading: const Icon(LucideIcons.plus, size: 16), - onPressed: - _controller.isSubmitting ? null : () => _openGroupForm(context), + onPressed: _controller.isSubmitting + ? null + : () => _openGroupForm(context), child: const Text('신규 등록'), ), ], toolbar: FilterBar( + actions: [ + ShadButton.outline( + onPressed: _controller.isLoading ? null : _applyFilters, + child: const Text('검색 적용'), + ), + if (showReset) + ShadButton.ghost( + onPressed: _controller.isLoading + ? null + : () { + _searchController.clear(); + _searchFocus.requestFocus(); + _controller.updateQuery(''); + _controller.updateDefaultFilter( + GroupDefaultFilter.all, + ); + _controller.updateStatusFilter(GroupStatusFilter.all); + _controller.fetch(page: 1); + }, + child: const Text('초기화'), + ), + ], children: [ SizedBox( width: 260, @@ -206,28 +231,6 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> { .toList(), ), ), - ShadButton.outline( - onPressed: _controller.isLoading ? null : _applyFilters, - child: const Text('검색 적용'), - ), - if (showReset) - ShadButton.ghost( - onPressed: _controller.isLoading - ? null - : () { - _searchController.clear(); - _searchFocus.requestFocus(); - _controller.updateQuery(''); - _controller.updateDefaultFilter( - GroupDefaultFilter.all, - ); - _controller.updateStatusFilter( - GroupStatusFilter.all, - ); - _controller.fetch(page: 1); - }, - child: const Text('초기화'), - ), ], ), child: ShadCard( @@ -272,26 +275,22 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> { child: Center(child: CircularProgressIndicator()), ) : groups.isEmpty - ? Padding( - padding: const EdgeInsets.all(32), - child: Text( - '조건에 맞는 그룹이 없습니다.', - style: theme.textTheme.muted, - ), - ) - : _GroupTable( - groups: groups, - dateFormat: _dateFormat, - onEdit: _controller.isSubmitting - ? null - : (group) => _openGroupForm(context, group: group), - onDelete: _controller.isSubmitting - ? null - : _confirmDelete, - onRestore: _controller.isSubmitting - ? null - : _restoreGroup, - ), + ? Padding( + padding: const EdgeInsets.all(32), + child: Text( + '조건에 맞는 그룹이 없습니다.', + style: theme.textTheme.muted, + ), + ) + : _GroupTable( + groups: groups, + dateFormat: _dateFormat, + onEdit: _controller.isSubmitting + ? null + : (group) => _openGroupForm(context, group: group), + onDelete: _controller.isSubmitting ? null : _confirmDelete, + onRestore: _controller.isSubmitting ? null : _restoreGroup, + ), ), ); }, @@ -352,199 +351,185 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> { final saving = ValueNotifier(false); final nameError = ValueNotifier(null); - await showDialog( + await SuperportDialog.show( context: context, - builder: (dialogContext) { - final theme = ShadTheme.of(dialogContext); - final materialTheme = Theme.of(dialogContext); - final navigator = Navigator.of(dialogContext); - return Dialog( - insetPadding: const EdgeInsets.all(24), - clipBehavior: Clip.antiAlias, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 540), - child: ShadCard( - title: Text( - isEdit ? '그룹 수정' : '그룹 등록', - style: theme.textTheme.h3, - ), - description: Text( - '그룹 정보를 ${isEdit ? '수정' : '입력'}하세요.', - style: theme.textTheme.muted, - ), - footer: ValueListenableBuilder( - 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 name = nameController.text.trim(); - final description = descriptionController.text - .trim(); - final note = noteController.text.trim(); + dialog: SuperportDialog( + title: isEdit ? '그룹 수정' : '그룹 등록', + description: '그룹 정보를 ${isEdit ? '수정' : '입력'}하세요.', + constraints: const BoxConstraints(maxWidth: 540), + actions: [ + ValueListenableBuilder( + valueListenable: saving, + builder: (dialogContext, isSaving, __) { + return ShadButton.ghost( + onPressed: isSaving + ? null + : () => Navigator.of(dialogContext).pop(), + child: const Text('취소'), + ); + }, + ), + ValueListenableBuilder( + valueListenable: saving, + builder: (dialogContext, isSaving, __) { + return ShadButton( + onPressed: isSaving + ? null + : () async { + final name = nameController.text.trim(); + final description = descriptionController.text.trim(); + final note = noteController.text.trim(); - nameError.value = name.isEmpty - ? '그룹명을 입력하세요.' - : null; + nameError.value = name.isEmpty ? '그룹명을 입력하세요.' : null; - if (nameError.value != null) { - return; - } + if (nameError.value != null) { + return; + } - saving.value = true; - final input = GroupInput( - groupName: name, - description: description.isEmpty - ? null - : description, - isDefault: isDefaultNotifier.value, - isActive: isActiveNotifier.value, - note: note.isEmpty ? null : note, - ); - final response = isEdit - ? await _controller.update(groupId!, input) - : await _controller.create(input); - saving.value = false; - if (response != null) { - if (!navigator.mounted) { - return; + saving.value = true; + final input = GroupInput( + groupName: name, + description: description.isEmpty ? null : description, + isDefault: isDefaultNotifier.value, + isActive: isActiveNotifier.value, + note: note.isEmpty ? null : note, + ); + final navigator = Navigator.of(dialogContext); + final response = isEdit + ? await _controller.update(groupId!, input) + : await _controller.create(input); + saving.value = false; + if (response != null) { + if (!navigator.mounted) { + return; + } + if (mounted) { + _showSnack(isEdit ? '그룹을 수정했습니다.' : '그룹을 등록했습니다.'); + } + navigator.pop(); + } + }, + child: Text(isEdit ? '저장' : '등록'), + ); + }, + ), + ], + child: StatefulBuilder( + builder: (dialogContext, _) { + final theme = ShadTheme.of(dialogContext); + final materialTheme = Theme.of(dialogContext); + + return SizedBox( + width: double.infinity, + child: SingleChildScrollView( + padding: const EdgeInsets.only(right: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ValueListenableBuilder( + valueListenable: nameError, + builder: (_, errorText, __) { + return _FormField( + label: '그룹명', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: nameController, + readOnly: isEdit, + onChanged: (_) { + if (nameController.text.trim().isNotEmpty) { + nameError.value = null; } - if (mounted) { - _showSnack( - isEdit ? '그룹을 수정했습니다.' : '그룹을 등록했습니다.', - ); - } - navigator.pop(true); - } - }, - child: Text(isEdit ? '저장' : '등록'), - ), - ], - ); - }, - ), - child: SizedBox( - width: double.infinity, - child: SingleChildScrollView( - padding: const EdgeInsets.only(right: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - ValueListenableBuilder( - valueListenable: nameError, - builder: (_, errorText, __) { - return _FormField( - label: '그룹명', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - controller: nameController, - readOnly: isEdit, - onChanged: (_) { - if (nameController.text.trim().isNotEmpty) { - nameError.value = null; - } - }, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), + }, + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, ), ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - _FormField( - label: '설명', - child: ShadTextarea(controller: descriptionController), - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: isDefaultNotifier, - builder: (_, value, __) { - return _FormField( - label: '기본여부', - child: Row( - children: [ - ShadSwitch( - value: value, - onChanged: saving.value - ? null - : (next) => - isDefaultNotifier.value = next, ), - const SizedBox(width: 8), - Text(value ? '기본 그룹' : '일반 그룹'), - ], - ), - ); - }, + ], + ), + ); + }, + ), + const SizedBox(height: 16), + _FormField( + label: '설명', + child: ShadTextarea(controller: descriptionController), + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: isDefaultNotifier, + builder: (_, value, __) { + return _FormField( + label: '기본여부', + child: Row( + children: [ + ShadSwitch( + value: value, + onChanged: saving.value + ? null + : (next) => isDefaultNotifier.value = next, + ), + const SizedBox(width: 8), + Text(value ? '기본 그룹' : '일반 그룹'), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: isActiveNotifier, + builder: (_, value, __) { + return _FormField( + label: '사용여부', + child: Row( + children: [ + ShadSwitch( + value: value, + onChanged: saving.value + ? null + : (next) => isActiveNotifier.value = next, + ), + const SizedBox(width: 8), + Text(value ? '사용' : '미사용'), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + _FormField( + label: '비고', + child: ShadTextarea(controller: noteController), + ), + if (existingGroup != null) ...[ + const SizedBox(height: 20), + Text( + '생성일시: ${_formatDateTime(existingGroup.createdAt)}', + style: theme.textTheme.small, ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: isActiveNotifier, - builder: (_, value, __) { - return _FormField( - label: '사용여부', - child: Row( - children: [ - ShadSwitch( - value: value, - onChanged: saving.value - ? null - : (next) => isActiveNotifier.value = next, - ), - const SizedBox(width: 8), - Text(value ? '사용' : '미사용'), - ], - ), - ); - }, + const SizedBox(height: 4), + Text( + '수정일시: ${_formatDateTime(existingGroup.updatedAt)}', + style: theme.textTheme.small, ), - const SizedBox(height: 16), - _FormField( - label: '비고', - child: ShadTextarea(controller: noteController), - ), - if (isEdit) ...[ - const SizedBox(height: 20), - Text( - '생성일시: ${_formatDateTime(existingGroup.createdAt)}', - style: theme.textTheme.small, - ), - const SizedBox(height: 4), - Text( - '수정일시: ${_formatDateTime(existingGroup.updatedAt)}', - style: theme.textTheme.small, - ), - ], ], - ), + ], ), ), - ), - ), - ); - }, + ); + }, + ), + ), ); nameController.dispose(); diff --git a/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart b/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart index 31e7e78..070b8bf 100644 --- a/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart +++ b/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart @@ -6,6 +6,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; +import 'package:superport_v2/widgets/components/superport_dialog.dart'; import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; @@ -167,7 +168,8 @@ class _GroupPermissionEnabledPageState ? false : (result.page * result.pageSize) < result.total; - final showReset = _searchController.text.isNotEmpty || + final showReset = + _searchController.text.isNotEmpty || _controller.groupFilter != null || _controller.menuFilter != null || _controller.statusFilter != GroupPermissionStatusFilter.all || @@ -191,6 +193,29 @@ class _GroupPermissionEnabledPageState ), ], toolbar: FilterBar( + actions: [ + ShadButton.outline( + onPressed: _controller.isLoading ? null : _applyFilters, + child: const Text('검색 적용'), + ), + if (showReset) + ShadButton.ghost( + onPressed: _controller.isLoading + ? null + : () { + _searchController.clear(); + _searchFocus.requestFocus(); + _controller.updateGroupFilter(null); + _controller.updateMenuFilter(null); + _controller.updateIncludeDeleted(false); + _controller.updateStatusFilter( + GroupPermissionStatusFilter.all, + ); + _controller.fetch(page: 1); + }, + child: const Text('초기화'), + ), + ], children: [ SizedBox( width: 260, @@ -208,16 +233,12 @@ class _GroupPermissionEnabledPageState key: ValueKey(_controller.groupFilter), initialValue: _controller.groupFilter, placeholder: Text( - _controller.groups.isEmpty - ? '그룹 로딩중...' - : '그룹 전체', + _controller.groups.isEmpty ? '그룹 로딩중...' : '그룹 전체', ), selectedOptionBuilder: (context, value) { if (value == null) { return Text( - _controller.groups.isEmpty - ? '그룹 로딩중...' - : '그룹 전체', + _controller.groups.isEmpty ? '그룹 로딩중...' : '그룹 전체', ); } final group = _controller.groups.firstWhere( @@ -230,10 +251,7 @@ class _GroupPermissionEnabledPageState _controller.updateGroupFilter(value); }, options: [ - const ShadOption( - value: null, - child: Text('그룹 전체'), - ), + const ShadOption(value: null, child: Text('그룹 전체')), ..._controller.groups.map( (group) => ShadOption( value: group.id, @@ -249,25 +267,18 @@ class _GroupPermissionEnabledPageState key: ValueKey(_controller.menuFilter), initialValue: _controller.menuFilter, placeholder: Text( - _controller.menus.isEmpty - ? '메뉴 로딩중...' - : '메뉴 전체', + _controller.menus.isEmpty ? '메뉴 로딩중...' : '메뉴 전체', ), selectedOptionBuilder: (context, value) { if (value == null) { return Text( - _controller.menus.isEmpty - ? '메뉴 로딩중...' - : '메뉴 전체', + _controller.menus.isEmpty ? '메뉴 로딩중...' : '메뉴 전체', ); } final menuItem = _controller.menus.firstWhere( (m) => m.id == value, - orElse: () => MenuItem( - id: value, - menuCode: '', - menuName: '', - ), + orElse: () => + MenuItem(id: value, menuCode: '', menuName: ''), ); return Text(menuItem.menuName); }, @@ -275,10 +286,7 @@ class _GroupPermissionEnabledPageState _controller.updateMenuFilter(value); }, options: [ - const ShadOption( - value: null, - child: Text('메뉴 전체'), - ), + const ShadOption(value: null, child: Text('메뉴 전체')), ..._controller.menus.map( (menuItem) => ShadOption( value: menuItem.id, @@ -322,24 +330,6 @@ class _GroupPermissionEnabledPageState const Text('삭제 포함'), ], ), - ShadButton.outline( - onPressed: _controller.isLoading ? null : _applyFilters, - child: const Text('검색 적용'), - ), - if (showReset) - ShadButton.ghost( - onPressed: _controller.isLoading - ? null - : () { - _searchController.clear(); - _searchFocus.requestFocus(); - _controller.updateGroupFilter(null); - _controller.updateMenuFilter(null); - _controller.updateIncludeDeleted(false); - _controller.fetch(page: 1); - }, - child: const Text('초기화'), - ), ], ), child: ShadCard( @@ -384,27 +374,27 @@ class _GroupPermissionEnabledPageState child: Center(child: CircularProgressIndicator()), ) : permissions.isEmpty - ? Padding( - padding: const EdgeInsets.all(32), - child: Text( - '조건에 맞는 권한이 없습니다.', - style: theme.textTheme.muted, - ), - ) - : _PermissionTable( - permissions: permissions, - dateFormat: _dateFormat, - onEdit: _controller.isSubmitting - ? null - : (permission) => - _openPermissionForm(context, permission: permission), - onDelete: _controller.isSubmitting - ? null - : _confirmDelete, - onRestore: _controller.isSubmitting - ? null - : _restorePermission, - ), + ? Padding( + padding: const EdgeInsets.all(32), + child: Text( + '조건에 맞는 권한이 없습니다.', + style: theme.textTheme.muted, + ), + ) + : _PermissionTable( + permissions: permissions, + dateFormat: _dateFormat, + onEdit: _controller.isSubmitting + ? null + : (permission) => _openPermissionForm( + context, + permission: permission, + ), + onDelete: _controller.isSubmitting ? null : _confirmDelete, + onRestore: _controller.isSubmitting + ? null + : _restorePermission, + ), ), ); }, @@ -430,311 +420,302 @@ class _GroupPermissionEnabledPageState BuildContext context, { GroupPermission? permission, }) async { - final isEdit = permission != null; - final permissionId = permission?.id; + final existingPermission = permission; + final isEdit = existingPermission != null; + final permissionId = existingPermission?.id; if (isEdit && permissionId == null) { _showSnack('ID 정보가 없어 수정할 수 없습니다.'); return; } - final groupNotifier = ValueNotifier(permission?.group.id); - final menuNotifier = ValueNotifier(permission?.menu.id); - final createNotifier = ValueNotifier(permission?.canCreate ?? false); - final readNotifier = ValueNotifier(permission?.canRead ?? true); - final updateNotifier = ValueNotifier(permission?.canUpdate ?? false); - final deleteNotifier = ValueNotifier(permission?.canDelete ?? false); - final activeNotifier = ValueNotifier(permission?.isActive ?? true); - final noteController = TextEditingController(text: permission?.note ?? ''); + final groupNotifier = ValueNotifier(existingPermission?.group.id); + final menuNotifier = ValueNotifier(existingPermission?.menu.id); + final createNotifier = ValueNotifier( + existingPermission?.canCreate ?? false, + ); + final readNotifier = ValueNotifier( + existingPermission?.canRead ?? true, + ); + final updateNotifier = ValueNotifier( + existingPermission?.canUpdate ?? false, + ); + final deleteNotifier = ValueNotifier( + existingPermission?.canDelete ?? false, + ); + final activeNotifier = ValueNotifier( + existingPermission?.isActive ?? true, + ); + final noteController = TextEditingController( + text: existingPermission?.note ?? '', + ); final saving = ValueNotifier(false); final groupError = ValueNotifier(null); final menuError = ValueNotifier(null); - await showDialog( + await SuperportDialog.show( context: context, - builder: (dialogContext) { - final theme = ShadTheme.of(dialogContext); - final materialTheme = Theme.of(dialogContext); - final navigator = Navigator.of(dialogContext); - return Dialog( - insetPadding: const EdgeInsets.all(24), - clipBehavior: Clip.antiAlias, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 600), - child: ShadCard( - title: Text( - isEdit ? '권한 수정' : '권한 등록', - style: theme.textTheme.h3, - ), - description: Text( - '그룹과 메뉴의 권한을 ${isEdit ? '수정' : '등록'}하세요.', - style: theme.textTheme.muted, - ), - footer: ValueListenableBuilder( - 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 groupId = groupNotifier.value; - final menuId = menuNotifier.value; - groupError.value = groupId == null - ? '그룹을 선택하세요.' - : null; - menuError.value = menuId == null - ? '메뉴를 선택하세요.' - : null; - if (groupError.value != null || - menuError.value != null) { - return; - } + dialog: SuperportDialog( + title: isEdit ? '권한 수정' : '권한 등록', + description: '그룹과 메뉴의 권한을 ${isEdit ? '수정' : '등록'}하세요.', + constraints: const BoxConstraints(maxWidth: 600), + secondaryAction: ValueListenableBuilder( + valueListenable: saving, + builder: (dialogContext, isSaving, __) { + return ShadButton.ghost( + onPressed: isSaving + ? null + : () => Navigator.of(dialogContext).pop(false), + child: const Text('취소'), + ); + }, + ), + primaryAction: ValueListenableBuilder( + valueListenable: saving, + builder: (dialogContext, isSaving, __) { + return ShadButton( + onPressed: isSaving + ? null + : () async { + final groupId = groupNotifier.value; + final menuId = menuNotifier.value; + groupError.value = groupId == null ? '그룹을 선택하세요.' : null; + menuError.value = menuId == null ? '메뉴를 선택하세요.' : null; + if (groupError.value != null || menuError.value != null) { + return; + } - saving.value = true; - final input = GroupPermissionInput( - groupId: groupId!, - menuId: menuId!, - canCreate: createNotifier.value, - canRead: readNotifier.value, - canUpdate: updateNotifier.value, - canDelete: deleteNotifier.value, - isActive: activeNotifier.value, - note: noteController.text.trim().isEmpty - ? null - : noteController.text.trim(), - ); - final response = isEdit - ? await _controller.update( - permissionId!, - input, - ) - : await _controller.create(input); - saving.value = false; - if (response != null) { - if (!navigator.mounted) { - return; - } - if (mounted) { - _showSnack( - isEdit ? '권한을 수정했습니다.' : '권한을 등록했습니다.', - ); - } - navigator.pop(true); + saving.value = true; + final trimmedNote = noteController.text.trim(); + final input = GroupPermissionInput( + groupId: groupId!, + menuId: menuId!, + canCreate: createNotifier.value, + canRead: readNotifier.value, + canUpdate: updateNotifier.value, + canDelete: deleteNotifier.value, + isActive: activeNotifier.value, + note: trimmedNote.isEmpty ? null : trimmedNote, + ); + final navigator = Navigator.of(dialogContext); + final response = isEdit + ? await _controller.update(permissionId!, 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: ValueListenableBuilder( + valueListenable: saving, + builder: (dialogContext, isSaving, __) { + final theme = ShadTheme.of(dialogContext); + final materialTheme = Theme.of(dialogContext); + return SingleChildScrollView( + padding: const EdgeInsets.only(right: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ValueListenableBuilder( + valueListenable: groupError, + builder: (_, errorText, __) { + return _FormField( + label: '그룹', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadSelect( + initialValue: groupNotifier.value, + placeholder: const Text('그룹을 선택하세요'), + selectedOptionBuilder: (context, value) { + if (value == null) { + return const Text('그룹을 선택하세요'); } + final groupId = value; + final group = _controller.groups.firstWhere( + (g) => g.id == groupId, + orElse: () => + Group(id: groupId, groupName: ''), + ); + return Text( + group.groupName.isEmpty + ? '그룹을 선택하세요' + : group.groupName, + ); }, - child: Text(isEdit ? '저장' : '등록'), - ), - ], - ); - }, - ), - child: SizedBox( - width: double.infinity, - child: SingleChildScrollView( - padding: const EdgeInsets.only(right: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - ValueListenableBuilder( - valueListenable: groupError, - builder: (_, errorText, __) { - return _FormField( - label: '그룹', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadSelect( - initialValue: groupNotifier.value, - placeholder: const Text('그룹을 선택하세요'), - selectedOptionBuilder: (context, value) { - if (value == null) { - return const Text('그룹을 선택하세요'); - } - final groupId = value; - final group = _controller.groups.firstWhere( - (g) => g.id == groupId, - orElse: () => - Group(id: groupId, groupName: ''), - ); - return Text( - group.groupName.isEmpty - ? '그룹을 선택하세요' - : group.groupName, - ); - }, - onChanged: saving.value || isEdit - ? null - : (value) { - groupNotifier.value = value; - if (value != null) { - groupError.value = null; - } - }, - options: [ - ..._controller.groups.map( - (group) => ShadOption( - value: group.id, - child: Text(group.groupName), - ), - ), - ], - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), + onChanged: isSaving || isEdit + ? null + : (value) { + groupNotifier.value = value; + if (value != null) { + groupError.value = null; + } + }, + options: [ + ..._controller.groups.map( + (group) => ShadOption( + value: group.id, + child: Text(group.groupName), ), + ), ], ), - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: menuError, - builder: (_, errorText, __) { - return _FormField( - label: '메뉴', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadSelect( - initialValue: menuNotifier.value, - placeholder: const Text('메뉴를 선택하세요'), - selectedOptionBuilder: (context, value) { - if (value == null) { - return const Text('메뉴를 선택하세요'); - } - final menuId = value; - final menu = _controller.menus.firstWhere( - (m) => m.id == menuId, - orElse: () => MenuItem( - id: menuId, - menuCode: '', - menuName: '', - ), - ); - return Text( - menu.menuName.isEmpty - ? '메뉴를 선택하세요' - : menu.menuName, - ); - }, - onChanged: saving.value || isEdit - ? null - : (value) { - menuNotifier.value = value; - if (value != null) { - menuError.value = null; - } - }, - options: [ - ..._controller.menus.map( - (menu) => ShadOption( - value: menu.id, - child: Text(menu.menuName), - ), - ), - ], - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, ), - ], - ), - ); - }, - ), - const SizedBox(height: 20), - _PermissionToggleRow( - label: '생성권한', - notifier: createNotifier, - enabled: !saving.value, - ), - const SizedBox(height: 12), - _PermissionToggleRow( - label: '조회권한', - notifier: readNotifier, - enabled: !saving.value, - ), - const SizedBox(height: 12), - _PermissionToggleRow( - label: '수정권한', - notifier: updateNotifier, - enabled: !saving.value, - ), - const SizedBox(height: 12), - _PermissionToggleRow( - label: '삭제권한', - notifier: deleteNotifier, - enabled: !saving.value, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: activeNotifier, - builder: (_, value, __) { - return _FormField( - label: '사용여부', - child: Row( - children: [ - ShadSwitch( - value: value, - onChanged: saving.value - ? null - : (next) => activeNotifier.value = next, ), - const SizedBox(width: 8), - Text(value ? '사용' : '미사용'), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - _FormField( - label: '비고', - child: ShadTextarea(controller: noteController), - ), - if (isEdit) ...[ - const SizedBox(height: 20), - Text( - '생성일시: ${_formatDateTime(permission.createdAt)}', - style: theme.textTheme.small, + ), + ], ), - const SizedBox(height: 4), - Text( - '수정일시: ${_formatDateTime(permission.updatedAt)}', - style: theme.textTheme.small, - ), - ], - ], + ); + }, ), - ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: menuError, + builder: (_, errorText, __) { + return _FormField( + label: '메뉴', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadSelect( + initialValue: menuNotifier.value, + placeholder: const Text('메뉴를 선택하세요'), + selectedOptionBuilder: (context, value) { + if (value == null) { + return const Text('메뉴를 선택하세요'); + } + final menuId = value; + final menu = _controller.menus.firstWhere( + (m) => m.id == menuId, + orElse: () => MenuItem( + id: menuId, + menuCode: '', + menuName: '', + ), + ); + return Text( + menu.menuName.isEmpty + ? '메뉴를 선택하세요' + : menu.menuName, + ); + }, + onChanged: isSaving || isEdit + ? null + : (value) { + menuNotifier.value = value; + if (value != null) { + menuError.value = null; + } + }, + options: [ + ..._controller.menus.map( + (menu) => ShadOption( + value: menu.id, + child: Text(menu.menuName), + ), + ), + ], + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 20), + _PermissionToggleRow( + label: '생성권한', + notifier: createNotifier, + enabled: !isSaving, + ), + const SizedBox(height: 12), + _PermissionToggleRow( + label: '조회권한', + notifier: readNotifier, + enabled: !isSaving, + ), + const SizedBox(height: 12), + _PermissionToggleRow( + label: '수정권한', + notifier: updateNotifier, + enabled: !isSaving, + ), + const SizedBox(height: 12), + _PermissionToggleRow( + label: '삭제권한', + notifier: deleteNotifier, + enabled: !isSaving, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: activeNotifier, + builder: (_, value, __) { + return _FormField( + label: '사용여부', + child: Row( + children: [ + ShadSwitch( + value: value, + onChanged: isSaving + ? null + : (next) => activeNotifier.value = next, + ), + const SizedBox(width: 8), + Text(value ? '사용' : '미사용'), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + _FormField( + label: '비고', + child: ShadTextarea(controller: noteController), + ), + if (existingPermission != null) ...[ + const SizedBox(height: 20), + Text( + '생성일시: ${_formatDateTime(existingPermission.createdAt)}', + style: theme.textTheme.small, + ), + const SizedBox(height: 4), + Text( + '수정일시: ${_formatDateTime(existingPermission.updatedAt)}', + style: theme.textTheme.small, + ), + ], + ], ), - ), - ), - ); - }, + ); + }, + ), + ), ); groupNotifier.dispose(); @@ -751,26 +732,29 @@ class _GroupPermissionEnabledPageState } Future _confirmDelete(GroupPermission permission) async { - final confirmed = await showDialog( + final confirmed = await SuperportDialog.show( context: context, - builder: (dialogContext) { - return AlertDialog( - title: const Text('권한 삭제'), - content: Text( + dialog: SuperportDialog( + title: '권한 삭제', + description: '"${permission.group.groupName}" → "${permission.menu.menuName}" 권한을 삭제하시겠습니까?', - ), - actions: [ - TextButton( + secondaryAction: Builder( + builder: (dialogContext) { + return ShadButton.ghost( onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('취소'), - ), - TextButton( + ); + }, + ), + primaryAction: Builder( + builder: (dialogContext) { + return ShadButton.destructive( onPressed: () => Navigator.of(dialogContext).pop(true), child: const Text('삭제'), - ), - ], - ); - }, + ); + }, + ), + ), ); if (confirmed == true && permission.id != null) { diff --git a/lib/features/masters/menu/presentation/pages/menu_page.dart b/lib/features/masters/menu/presentation/pages/menu_page.dart index a201494..a3676dd 100644 --- a/lib/features/masters/menu/presentation/pages/menu_page.dart +++ b/lib/features/masters/menu/presentation/pages/menu_page.dart @@ -5,6 +5,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; +import 'package:superport_v2/widgets/components/superport_dialog.dart'; import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; @@ -151,7 +152,8 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { ? false : (result.page * result.pageSize) < result.total; - final showReset = _searchController.text.isNotEmpty || + final showReset = + _searchController.text.isNotEmpty || _controller.parentFilter != null || _controller.statusFilter != menu.MenuStatusFilter.all || _controller.includeDeleted; @@ -167,12 +169,36 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { actions: [ ShadButton( leading: const Icon(LucideIcons.plus, size: 16), - onPressed: - _controller.isSubmitting ? null : () => _openMenuForm(context), + onPressed: _controller.isSubmitting + ? null + : () => _openMenuForm(context), child: const Text('신규 등록'), ), ], toolbar: FilterBar( + actions: [ + ShadButton.outline( + onPressed: _controller.isLoading ? null : _applyFilters, + child: const Text('검색 적용'), + ), + if (showReset) + ShadButton.ghost( + onPressed: _controller.isLoading + ? null + : () { + _searchController.clear(); + _searchFocus.requestFocus(); + _controller.updateQuery(''); + _controller.updateParentFilter(null); + _controller.updateStatusFilter( + menu.MenuStatusFilter.all, + ); + _controller.updateIncludeDeleted(false); + _controller.fetch(page: 1); + }, + child: const Text('초기화'), + ), + ], children: [ SizedBox( width: 260, @@ -195,18 +221,13 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { selectedOptionBuilder: (context, value) { if (value == null) { return Text( - _controller.isLoadingParents - ? '상위 로딩중...' - : '상위 전체', + _controller.isLoadingParents ? '상위 로딩중...' : '상위 전체', ); } final target = _controller.parents.firstWhere( (menuItem) => menuItem.id == value, - orElse: () => MenuItem( - id: value, - menuCode: '', - menuName: '', - ), + orElse: () => + MenuItem(id: value, menuCode: '', menuName: ''), ); final label = target.menuName.isEmpty ? '상위 전체' @@ -220,10 +241,7 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { _controller.fetch(page: 1); }, options: [ - const ShadOption( - value: null, - child: Text('상위 전체'), - ), + const ShadOption(value: null, child: Text('상위 전체')), ..._controller.parents.map( (menuItem) => ShadOption( value: menuItem.id, @@ -269,27 +287,6 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { const Text('삭제 포함'), ], ), - ShadButton.outline( - onPressed: _controller.isLoading ? null : _applyFilters, - child: const Text('검색 적용'), - ), - if (showReset) - ShadButton.ghost( - onPressed: _controller.isLoading - ? null - : () { - _searchController.clear(); - _searchFocus.requestFocus(); - _controller.updateQuery(''); - _controller.updateParentFilter(null); - _controller.updateStatusFilter( - menu.MenuStatusFilter.all, - ); - _controller.updateIncludeDeleted(false); - _controller.fetch(page: 1); - }, - child: const Text('초기화'), - ), ], ), child: ShadCard( @@ -334,27 +331,22 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { child: Center(child: CircularProgressIndicator()), ) : menus.isEmpty - ? Padding( - padding: const EdgeInsets.all(32), - child: Text( - '조건에 맞는 메뉴가 없습니다.', - style: theme.textTheme.muted, - ), - ) - : _MenuTable( - menus: menus, - dateFormat: _dateFormat, - onEdit: _controller.isSubmitting - ? null - : (menuItem) => - _openMenuForm(context, menu: menuItem), - onDelete: _controller.isSubmitting - ? null - : _confirmDelete, - onRestore: _controller.isSubmitting - ? null - : _restoreMenu, - ), + ? Padding( + padding: const EdgeInsets.all(32), + child: Text( + '조건에 맞는 메뉴가 없습니다.', + style: theme.textTheme.muted, + ), + ) + : _MenuTable( + menus: menus, + dateFormat: _dateFormat, + onEdit: _controller.isSubmitting + ? null + : (menuItem) => _openMenuForm(context, menu: menuItem), + onDelete: _controller.isSubmitting ? null : _confirmDelete, + onRestore: _controller.isSubmitting ? null : _restoreMenu, + ), ), ); }, @@ -410,302 +402,283 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { final nameError = ValueNotifier(null); final orderError = ValueNotifier(null); - await showDialog( + await SuperportDialog.show( context: context, - builder: (dialogContext) { - final theme = ShadTheme.of(dialogContext); - final materialTheme = Theme.of(dialogContext); - final navigator = Navigator.of(dialogContext); - return Dialog( - insetPadding: const EdgeInsets.all(24), - clipBehavior: Clip.antiAlias, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560), - child: ShadCard( - title: Text( - isEdit ? '메뉴 수정' : '메뉴 등록', - style: theme.textTheme.h3, - ), - description: Text( - '메뉴 정보를 ${isEdit ? '수정' : '입력'}하세요.', - style: theme.textTheme.muted, - ), - footer: ValueListenableBuilder( - 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 path = pathController.text.trim(); - final orderText = orderController.text.trim(); - final note = noteController.text.trim(); + dialog: SuperportDialog( + title: isEdit ? '메뉴 수정' : '메뉴 등록', + description: '메뉴 정보를 ${isEdit ? '수정' : '입력'}하세요.', + constraints: const BoxConstraints(maxWidth: 560), + secondaryAction: ValueListenableBuilder( + valueListenable: saving, + builder: (dialogContext, isSaving, __) { + return ShadButton.ghost( + onPressed: isSaving + ? null + : () => Navigator.of(dialogContext).pop(false), + child: const Text('취소'), + ); + }, + ), + primaryAction: ValueListenableBuilder( + valueListenable: saving, + builder: (dialogContext, isSaving, __) { + return ShadButton( + onPressed: isSaving + ? null + : () async { + final code = codeController.text.trim(); + final name = nameController.text.trim(); + final path = pathController.text.trim(); + final orderText = orderController.text.trim(); + final note = noteController.text.trim(); - codeError.value = code.isEmpty - ? '메뉴코드를 입력하세요.' - : null; - nameError.value = name.isEmpty - ? '메뉴명을 입력하세요.' - : null; + codeError.value = code.isEmpty ? '메뉴코드를 입력하세요.' : null; + nameError.value = name.isEmpty ? '메뉴명을 입력하세요.' : null; - int? orderValue; - if (orderText.isNotEmpty) { - orderValue = int.tryParse(orderText); - if (orderValue == null) { - orderError.value = '표시순서는 숫자여야 합니다.'; - } else { - orderError.value = null; - } - } else { - orderError.value = null; - } + int? orderValue; + if (orderText.isNotEmpty) { + orderValue = int.tryParse(orderText); + orderError.value = orderValue == null + ? '표시순서는 숫자여야 합니다.' + : null; + } else { + orderError.value = null; + } - if (codeError.value != null || - nameError.value != null || - orderError.value != null) { - return; - } + if (codeError.value != null || + nameError.value != null || + orderError.value != null) { + return; + } - saving.value = true; - final input = MenuInput( - menuCode: code, - menuName: name, - parentMenuId: parentNotifier.value, - path: path.isEmpty ? null : path, - displayOrder: orderValue, - isActive: isActiveNotifier.value, - note: note.isEmpty ? null : note, - ); - final response = isEdit - ? await _controller.update(menuId!, input) - : await _controller.create(input); - saving.value = false; - if (response != null) { - if (!navigator.mounted) { - return; - } - if (mounted) { - _showSnack( - isEdit ? '메뉴를 수정했습니다.' : '메뉴를 등록했습니다.', - ); - } - navigator.pop(true); + saving.value = true; + final input = MenuInput( + menuCode: code, + menuName: name, + parentMenuId: parentNotifier.value, + path: path.isEmpty ? null : path, + displayOrder: orderValue, + isActive: isActiveNotifier.value, + note: note.isEmpty ? null : note, + ); + final navigator = Navigator.of(dialogContext); + final response = isEdit + ? await _controller.update(menuId!, input) + : await _controller.create(input); + saving.value = false; + if (response != null) { + if (!navigator.mounted) { + return; + } + if (mounted) { + _showSnack(isEdit ? '메뉴를 수정했습니다.' : '메뉴를 등록했습니다.'); + } + navigator.pop(true); + } + }, + child: Text(isEdit ? '저장' : '등록'), + ); + }, + ), + child: ValueListenableBuilder( + valueListenable: saving, + builder: (dialogContext, isSaving, __) { + final theme = ShadTheme.of(dialogContext); + final materialTheme = Theme.of(dialogContext); + return SingleChildScrollView( + padding: const EdgeInsets.only(right: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ValueListenableBuilder( + valueListenable: codeError, + builder: (_, errorText, __) { + return _FormField( + label: '메뉴코드', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: codeController, + readOnly: isEdit, + onChanged: (_) { + if (codeController.text.trim().isNotEmpty) { + codeError.value = null; } }, - child: Text(isEdit ? '저장' : '등록'), - ), - ], - ); - }, - ), - child: SizedBox( - width: double.infinity, - child: SingleChildScrollView( - padding: const EdgeInsets.only(right: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - ValueListenableBuilder( - valueListenable: codeError, - builder: (_, errorText, __) { - return _FormField( - label: '메뉴코드', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - controller: codeController, - readOnly: isEdit, - onChanged: (_) { - if (codeController.text.trim().isNotEmpty) { - codeError.value = null; - } - }, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: nameError, - builder: (_, errorText, __) { - return _FormField( - label: '메뉴명', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - controller: nameController, - onChanged: (_) { - if (nameController.text.trim().isNotEmpty) { - nameError.value = null; - } - }, ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: parentNotifier, - builder: (_, value, __) { - return _FormField( - label: '상위메뉴', - child: ShadSelect( - initialValue: value, - placeholder: const Text('최상위'), - selectedOptionBuilder: (context, selected) { - if (selected == null) { - return const Text('최상위'); - } - final target = _controller.parents.firstWhere( - (item) => item.id == selected, - orElse: () => MenuItem( - id: selected, - menuCode: '', - menuName: '', - ), - ); - final label = target.menuName.isEmpty - ? '최상위' - : target.menuName; - return Text(label); - }, - onChanged: saving.value - ? null - : (next) => parentNotifier.value = next, - options: [ - const ShadOption( - value: null, - child: Text('최상위'), - ), - ..._controller.parents - .where((item) => item.id != menuId) - .map( - (menuItem) => ShadOption( - value: menuItem.id, - child: Text(menuItem.menuName), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - _FormField( - label: '경로', - child: ShadInput(controller: pathController), - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: orderError, - builder: (_, errorText, __) { - return _FormField( - label: '표시순서', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - controller: orderController, - keyboardType: TextInputType.number, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: isActiveNotifier, - builder: (_, value, __) { - return _FormField( - label: '사용여부', - child: Row( - children: [ - ShadSwitch( - value: value, - onChanged: saving.value - ? null - : (next) => isActiveNotifier.value = next, - ), - const SizedBox(width: 8), - Text(value ? '사용' : '미사용'), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - _FormField( - label: '비고', - child: ShadTextarea(controller: noteController), - ), - if (isEdit) ...[ - const SizedBox(height: 20), - Text( - '생성일시: ${_formatDateTime(existingMenu.createdAt)}', - style: theme.textTheme.small, + ), + ], ), - const SizedBox(height: 4), - Text( - '수정일시: ${_formatDateTime(existingMenu.updatedAt)}', - style: theme.textTheme.small, - ), - ], - ], + ); + }, ), - ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: nameError, + builder: (_, errorText, __) { + return _FormField( + label: '메뉴명', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: nameController, + onChanged: (_) { + if (nameController.text.trim().isNotEmpty) { + nameError.value = null; + } + }, + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: parentNotifier, + builder: (_, value, __) { + return _FormField( + label: '상위메뉴', + child: ShadSelect( + initialValue: value, + placeholder: const Text('최상위'), + selectedOptionBuilder: (context, selected) { + if (selected == null) { + return const Text('최상위'); + } + final target = _controller.parents.firstWhere( + (item) => item.id == selected, + orElse: () => MenuItem( + id: selected, + menuCode: '', + menuName: '', + ), + ); + final label = target.menuName.isEmpty + ? '최상위' + : target.menuName; + return Text(label); + }, + onChanged: isSaving + ? null + : (next) => parentNotifier.value = next, + options: [ + const ShadOption( + value: null, + child: Text('최상위'), + ), + ..._controller.parents + .where((item) => item.id != menuId) + .map( + (menuItem) => ShadOption( + value: menuItem.id, + child: Text(menuItem.menuName), + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + _FormField( + label: '경로', + child: ShadInput(controller: pathController), + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: orderError, + builder: (_, errorText, __) { + return _FormField( + label: '표시순서', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: orderController, + keyboardType: TextInputType.number, + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: isActiveNotifier, + builder: (_, value, __) { + return _FormField( + label: '사용여부', + child: Row( + children: [ + ShadSwitch( + value: value, + onChanged: isSaving + ? null + : (next) => isActiveNotifier.value = next, + ), + const SizedBox(width: 8), + Text(value ? '사용' : '미사용'), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + _FormField( + label: '비고', + child: ShadTextarea(controller: noteController), + ), + if (existingMenu != null) ...[ + const SizedBox(height: 20), + Text( + '생성일시: ${_formatDateTime(existingMenu.createdAt)}', + style: theme.textTheme.small, + ), + const SizedBox(height: 4), + Text( + '수정일시: ${_formatDateTime(existingMenu.updatedAt)}', + style: theme.textTheme.small, + ), + ], + ], ), - ), - ), - ); - }, + ); + }, + ), + ), ); codeController.dispose(); @@ -722,24 +695,28 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { } Future _confirmDelete(MenuItem menu) async { - final confirmed = await showDialog( + final confirmed = await SuperportDialog.show( context: context, - builder: (dialogContext) { - return AlertDialog( - title: const Text('메뉴 삭제'), - content: Text('"${menu.menuName}" 메뉴를 삭제하시겠습니까?'), - actions: [ - TextButton( + dialog: SuperportDialog( + title: '메뉴 삭제', + description: '"${menu.menuName}" 메뉴를 삭제하시겠습니까?', + secondaryAction: Builder( + builder: (dialogContext) { + return ShadButton.ghost( onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('취소'), - ), - TextButton( + ); + }, + ), + primaryAction: Builder( + builder: (dialogContext) { + return ShadButton.destructive( onPressed: () => Navigator.of(dialogContext).pop(true), child: const Text('삭제'), - ), - ], - ); - }, + ); + }, + ), + ), ); if (confirmed == true && menu.id != null) { diff --git a/lib/features/masters/product/presentation/controllers/product_controller.dart b/lib/features/masters/product/presentation/controllers/product_controller.dart index 21f2594..964cb13 100644 --- a/lib/features/masters/product/presentation/controllers/product_controller.dart +++ b/lib/features/masters/product/presentation/controllers/product_controller.dart @@ -11,6 +11,8 @@ import '../../domain/repositories/product_repository.dart'; enum ProductStatusFilter { all, activeOnly, inactiveOnly } class ProductController extends ChangeNotifier { + static const int defaultPageSize = 20; + ProductController({ required ProductRepository productRepository, required VendorRepository vendorRepository, @@ -31,6 +33,7 @@ class ProductController extends ChangeNotifier { int? _vendorFilter; int? _uomFilter; ProductStatusFilter _statusFilter = ProductStatusFilter.all; + int _pageSize = defaultPageSize; String? _errorMessage; List _vendorOptions = const []; @@ -44,6 +47,7 @@ class ProductController extends ChangeNotifier { int? get vendorFilter => _vendorFilter; int? get uomFilter => _uomFilter; ProductStatusFilter get statusFilter => _statusFilter; + int get pageSize => _pageSize; String? get errorMessage => _errorMessage; List get vendorOptions => _vendorOptions; List get uomOptions => _uomOptions; @@ -60,13 +64,16 @@ class ProductController extends ChangeNotifier { }; final response = await _productRepository.list( page: page, - pageSize: _result?.pageSize ?? 20, + pageSize: _pageSize, query: _query.isEmpty ? null : _query, vendorId: _vendorFilter, uomId: _uomFilter, isActive: isActive, ); _result = response; + if (response.pageSize > 0 && response.pageSize != _pageSize) { + _pageSize = response.pageSize; + } } catch (e) { _errorMessage = e.toString(); } finally { @@ -92,25 +99,45 @@ class ProductController extends ChangeNotifier { } void updateQuery(String value) { + if (_query == value) { + return; + } _query = value; notifyListeners(); } void updateVendorFilter(int? vendorId) { + if (_vendorFilter == vendorId) { + return; + } _vendorFilter = vendorId; notifyListeners(); } void updateUomFilter(int? uomId) { + if (_uomFilter == uomId) { + return; + } _uomFilter = uomId; notifyListeners(); } void updateStatusFilter(ProductStatusFilter filter) { + if (_statusFilter == filter) { + return; + } _statusFilter = filter; notifyListeners(); } + void updatePageSize(int size) { + if (size <= 0 || _pageSize == size) { + return; + } + _pageSize = size; + notifyListeners(); + } + Future create(ProductInput input) async { _setSubmitting(true); try { diff --git a/lib/features/masters/product/presentation/pages/product_page.dart b/lib/features/masters/product/presentation/pages/product_page.dart index c9c28d9..8537b5b 100644 --- a/lib/features/masters/product/presentation/pages/product_page.dart +++ b/lib/features/masters/product/presentation/pages/product_page.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; +import 'package:superport_v2/widgets/components/superport_dialog.dart'; +import 'package:superport_v2/widgets/components/superport_table.dart'; import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; @@ -17,7 +20,9 @@ import '../../domain/repositories/product_repository.dart'; import '../controllers/product_controller.dart'; class ProductPage extends StatelessWidget { - const ProductPage({super.key}); + const ProductPage({super.key, required this.routeUri}); + + final Uri routeUri; @override Widget build(BuildContext context) { @@ -65,12 +70,14 @@ class ProductPage extends StatelessWidget { ); } - return const _ProductEnabledPage(); + return _ProductEnabledPage(routeUri: routeUri); } } class _ProductEnabledPage extends StatefulWidget { - const _ProductEnabledPage(); + const _ProductEnabledPage({required this.routeUri}); + + final Uri routeUri; @override State<_ProductEnabledPage> createState() => _ProductEnabledPageState(); @@ -83,6 +90,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm'); bool _lookupsLoaded = false; String? _lastError; + String? _lastRouteSignature; @override void initState() { @@ -92,12 +100,20 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { vendorRepository: GetIt.I(), uomRepository: GetIt.I(), )..addListener(_handleControllerUpdate); - WidgetsBinding.instance.addPostFrameCallback((_) async { - await Future.wait([_controller.loadLookups(), _controller.fetch()]); - setState(() { - _lookupsLoaded = true; - }); - }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _maybeApplyRoute(); + } + + @override + void didUpdateWidget(covariant _ProductEnabledPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.routeUri != oldWidget.routeUri) { + _maybeApplyRoute(); + } } void _handleControllerUpdate() { @@ -138,7 +154,8 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { ? false : (result.page * result.pageSize) < result.total; - final showReset = _searchController.text.isNotEmpty || + final showReset = + _searchController.text.isNotEmpty || _controller.vendorFilter != null || _controller.uomFilter != null || _controller.statusFilter != ProductStatusFilter.all; @@ -161,6 +178,29 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { ), ], toolbar: FilterBar( + actions: [ + ShadButton.outline( + onPressed: _controller.isLoading ? null : _applyFilters, + child: const Text('검색 적용'), + ), + if (showReset) + ShadButton.ghost( + onPressed: _controller.isLoading + ? null + : () { + _searchController.clear(); + _searchFocus.requestFocus(); + _updateRoute( + page: 1, + queryOverride: '', + vendorOverride: null, + uomOverride: null, + statusOverride: ProductStatusFilter.all, + ); + }, + child: const Text('초기화'), + ), + ], children: [ SizedBox( width: 260, @@ -184,16 +224,15 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { } final vendor = _controller.vendorOptions.firstWhere( (v) => v.id == value, - orElse: () => Vendor(id: value, vendorCode: '', vendorName: ''), + orElse: () => + Vendor(id: value, vendorCode: '', vendorName: ''), ); return Text(vendor.vendorName); }, - onChanged: (value) => _controller.updateVendorFilter(value), + onChanged: (value) => + _updateRoute(page: 1, vendorOverride: value), options: [ - const ShadOption( - value: null, - child: Text('제조사 전체'), - ), + const ShadOption(value: null, child: Text('제조사 전체')), ..._controller.vendorOptions.map( (vendor) => ShadOption( value: vendor.id, @@ -219,12 +258,10 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { ); return Text(uom.uomName); }, - onChanged: (value) => _controller.updateUomFilter(value), + onChanged: (value) => + _updateRoute(page: 1, uomOverride: value), options: [ - const ShadOption( - value: null, - child: Text('단위 전체'), - ), + const ShadOption(value: null, child: Text('단위 전체')), ..._controller.uomOptions.map( (uom) => ShadOption( value: uom.id, @@ -243,7 +280,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { Text(_statusLabel(filter)), onChanged: (value) { if (value == null) return; - _controller.updateStatusFilter(value); + _updateRoute(page: 1, statusOverride: value); }, options: ProductStatusFilter.values .map( @@ -255,27 +292,6 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { .toList(), ), ), - ShadButton.outline( - onPressed: _controller.isLoading ? null : _applyFilters, - child: const Text('검색 적용'), - ), - if (showReset) - ShadButton.ghost( - onPressed: _controller.isLoading - ? null - : () { - _searchController.clear(); - _searchFocus.requestFocus(); - _controller.updateQuery(''); - _controller.updateVendorFilter(null); - _controller.updateUomFilter(null); - _controller.updateStatusFilter( - ProductStatusFilter.all, - ); - _controller.fetch(page: 1); - }, - child: const Text('초기화'), - ), ], ), child: ShadCard( @@ -299,7 +315,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { size: ShadButtonSize.sm, onPressed: _controller.isLoading || currentPage <= 1 ? null - : () => _controller.fetch(page: currentPage - 1), + : () => _goToPage(currentPage - 1), child: const Text('이전'), ), const SizedBox(width: 8), @@ -307,7 +323,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { size: ShadButtonSize.sm, onPressed: _controller.isLoading || !hasNext ? null - : () => _controller.fetch(page: currentPage + 1), + : () => _goToPage(currentPage + 1), child: const Text('다음'), ), ], @@ -320,27 +336,25 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { child: Center(child: CircularProgressIndicator()), ) : products.isEmpty - ? Padding( - padding: const EdgeInsets.all(32), - child: Text( - '조건에 맞는 제품이 없습니다.', - style: theme.textTheme.muted, - ), - ) - : _ProductTable( - products: products, - dateFormat: _dateFormat, - onEdit: _controller.isSubmitting - ? null - : (product) => - _openProductForm(context, product: product), - onDelete: _controller.isSubmitting - ? null - : _confirmDelete, - onRestore: _controller.isSubmitting - ? null - : _restoreProduct, - ), + ? Padding( + padding: const EdgeInsets.all(32), + child: Text( + '조건에 맞는 제품이 없습니다.', + style: theme.textTheme.muted, + ), + ) + : _ProductTable( + products: products, + dateFormat: _dateFormat, + onEdit: _controller.isSubmitting + ? null + : (product) => + _openProductForm(context, product: product), + onDelete: _controller.isSubmitting ? null : _confirmDelete, + onRestore: _controller.isSubmitting + ? null + : _restoreProduct, + ), ), ); }, @@ -348,8 +362,9 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { } void _applyFilters() { - _controller.updateQuery(_searchController.text.trim()); - _controller.fetch(page: 1); + final keyword = _searchController.text.trim(); + _controller.updateQuery(keyword); + _updateRoute(page: 1, queryOverride: keyword); } String _statusLabel(ProductStatusFilter filter) { @@ -363,6 +378,126 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { } } + void _maybeApplyRoute() { + final signature = widget.routeUri.toString(); + if (_lastRouteSignature == signature) { + return; + } + _lastRouteSignature = signature; + _applyRouteParameters(); + } + + void _applyRouteParameters() { + final params = widget.routeUri.queryParameters; + final query = params['q'] ?? ''; + final vendorParam = int.tryParse(params['vendor'] ?? ''); + final uomParam = int.tryParse(params['uom'] ?? ''); + final status = _statusFromParam(params['status']); + final pageSizeParam = int.tryParse(params['page_size'] ?? ''); + final pageParam = int.tryParse(params['page'] ?? ''); + + _searchController.text = query; + _controller.updateQuery(query); + _controller.updateVendorFilter(vendorParam); + _controller.updateUomFilter(uomParam); + _controller.updateStatusFilter(status); + if (pageSizeParam != null && pageSizeParam > 0) { + _controller.updatePageSize(pageSizeParam); + } + + final page = pageParam != null && pageParam > 0 ? pageParam : 1; + WidgetsBinding.instance.addPostFrameCallback((_) async { + setState(() { + _lookupsLoaded = false; + }); + await Future.wait([ + _controller.loadLookups(), + _controller.fetch(page: page), + ]); + if (mounted) { + setState(() { + _lookupsLoaded = true; + }); + } + }); + } + + void _goToPage(int page) { + if (page < 1) { + page = 1; + } + _updateRoute(page: page); + } + + void _updateRoute({ + required int page, + String? queryOverride, + int? vendorOverride, + int? uomOverride, + ProductStatusFilter? statusOverride, + int? pageSizeOverride, + }) { + final query = queryOverride ?? _controller.query; + final vendor = vendorOverride ?? _controller.vendorFilter; + final uom = uomOverride ?? _controller.uomFilter; + final status = statusOverride ?? _controller.statusFilter; + final pageSize = pageSizeOverride ?? _controller.pageSize; + + final params = {}; + 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 _openProductForm( BuildContext context, { Product? product, @@ -398,339 +533,314 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { final vendorError = ValueNotifier(null); final uomError = ValueNotifier(null); - await showDialog( + await SuperportDialog.show( context: parentContext, - builder: (dialogContext) { - final theme = ShadTheme.of(dialogContext); - final materialTheme = Theme.of(dialogContext); - final navigator = Navigator.of(dialogContext); - return Dialog( - insetPadding: const EdgeInsets.all(24), - clipBehavior: Clip.antiAlias, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560), - child: ShadCard( - title: Text( - isEdit ? '제품 수정' : '제품 등록', - style: theme.textTheme.h3, - ), - description: Text( - '제품 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.', - style: theme.textTheme.muted, - ), - footer: ValueListenableBuilder( - valueListenable: saving, - builder: (_, isSaving, __) { - return Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - ShadButton.ghost( - onPressed: isSaving ? null : () => navigator.pop(false), - child: const Text('취소'), - ), - const SizedBox(width: 12), - ShadButton( - onPressed: isSaving - ? null - : () async { - final code = codeController.text.trim(); - final name = nameController.text.trim(); - final note = noteController.text.trim(); - final vendorId = vendorNotifier.value; - final uomId = uomNotifier.value; + dialog: SuperportDialog( + title: isEdit ? '제품 수정' : '제품 등록', + description: '제품 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.', + primaryAction: ValueListenableBuilder( + valueListenable: saving, + builder: (context, isSaving, _) { + return ShadButton( + onPressed: isSaving + ? null + : () async { + final code = codeController.text.trim(); + final name = nameController.text.trim(); + final note = noteController.text.trim(); + final vendorId = vendorNotifier.value; + final uomId = uomNotifier.value; - codeError.value = code.isEmpty - ? '제품코드를 입력하세요.' - : null; - nameError.value = name.isEmpty - ? '제품명을 입력하세요.' - : null; - vendorError.value = vendorId == null - ? '제조사를 선택하세요.' - : null; - uomError.value = uomId == null - ? '단위를 선택하세요.' - : null; + codeError.value = code.isEmpty ? '제품코드를 입력하세요.' : null; + nameError.value = name.isEmpty ? '제품명을 입력하세요.' : null; + vendorError.value = vendorId == null + ? '제조사를 선택하세요.' + : null; + uomError.value = uomId == null ? '단위를 선택하세요.' : null; - if (codeError.value != null || - nameError.value != null || - vendorError.value != null || - uomError.value != null) { - return; - } + if (codeError.value != null || + nameError.value != null || + vendorError.value != null || + uomError.value != null) { + return; + } - saving.value = true; - final input = ProductInput( - productCode: code, - productName: name, - vendorId: vendorId!, - uomId: uomId!, - isActive: isActiveNotifier.value, - note: note.isEmpty ? null : note, - ); - final response = isEdit - ? await _controller.update( - productId!, - input, - ) - : await _controller.create(input); - saving.value = false; - if (response != null) { - if (!navigator.mounted) { - return; - } - if (mounted) { - _showSnack( - isEdit ? '제품을 수정했습니다.' : '제품을 등록했습니다.', - ); - } - navigator.pop(true); + saving.value = true; + final input = ProductInput( + productCode: code, + productName: name, + vendorId: vendorId!, + uomId: uomId!, + isActive: isActiveNotifier.value, + note: note.isEmpty ? null : note, + ); + final navigator = Navigator.of(context); + final response = isEdit + ? await _controller.update(productId!, 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( + valueListenable: saving, + builder: (context, isSaving, _) { + return ShadButton.ghost( + onPressed: isSaving + ? null + : () => Navigator.of(context).pop(false), + child: const Text('취소'), + ); + }, + ), + child: ValueListenableBuilder( + valueListenable: saving, + builder: (context, isSaving, _) { + final theme = ShadTheme.of(context); + final materialTheme = Theme.of(context); + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ValueListenableBuilder( + valueListenable: codeError, + builder: (_, errorText, __) { + return _FormField( + label: '제품코드', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: codeController, + readOnly: isEdit, + onChanged: (_) { + if (codeController.text.trim().isNotEmpty) { + codeError.value = null; } }, - child: Text(isEdit ? '저장' : '등록'), - ), - ], - ); - }, - ), - child: SingleChildScrollView( - padding: const EdgeInsets.only(right: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - ValueListenableBuilder( - valueListenable: codeError, - builder: (_, errorText, __) { - return _FormField( - label: '제품코드', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - controller: codeController, - readOnly: isEdit, - onChanged: (_) { - if (codeController.text.trim().isNotEmpty) { - codeError.value = null; - } - }, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, ), ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: nameError, - builder: (_, errorText, __) { - return _FormField( - label: '제품명', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - controller: nameController, - onChanged: (_) { - if (nameController.text.trim().isNotEmpty) { - nameError.value = null; - } - }, ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: nameError, + builder: (_, errorText, __) { + return _FormField( + label: '제품명', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: nameController, + onChanged: (_) { + if (nameController.text.trim().isNotEmpty) { + nameError.value = null; + } + }, + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, ), ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: vendorNotifier, - builder: (_, value, __) { - return ValueListenableBuilder( - valueListenable: vendorError, - builder: (_, errorText, __) { - return _FormField( - label: '제조사', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadSelect( - initialValue: value, - onChanged: saving.value - ? null - : (next) { - vendorNotifier.value = next; - vendorError.value = null; - }, - options: _controller.vendorOptions - .map( - (vendor) => ShadOption( - value: vendor.id, - child: Text(vendor.vendorName), - ), - ) - .toList(), - placeholder: const Text('제조사를 선택하세요'), - selectedOptionBuilder: (context, selected) { - if (selected == null) { - return const Text('제조사를 선택하세요'); - } - final vendor = _controller.vendorOptions - .firstWhere( - (v) => v.id == selected, - orElse: () => Vendor( - id: selected, - vendorCode: '', - vendorName: '', - ), - ); - return Text(vendor.vendorName); - }, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: - materialTheme.colorScheme.error, + ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: vendorNotifier, + builder: (_, value, __) { + return ValueListenableBuilder( + valueListenable: vendorError, + builder: (_, errorText, __) { + return _FormField( + label: '제조사', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadSelect( + initialValue: value, + onChanged: saving.value + ? null + : (next) { + vendorNotifier.value = next; + vendorError.value = null; + }, + options: _controller.vendorOptions + .map( + (vendor) => ShadOption( + value: vendor.id, + child: Text(vendor.vendorName), ), + ) + .toList(), + placeholder: const Text('제조사를 선택하세요'), + selectedOptionBuilder: (context, selected) { + if (selected == null) { + return const Text('제조사를 선택하세요'); + } + final vendor = _controller.vendorOptions + .firstWhere( + (v) => v.id == selected, + orElse: () => Vendor( + id: selected, + vendorCode: '', + vendorName: '', + ), + ); + return Text(vendor.vendorName); + }, + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, ), ), - ], - ), - ); - }, - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: uomNotifier, - builder: (_, value, __) { - return ValueListenableBuilder( - valueListenable: uomError, - builder: (_, errorText, __) { - return _FormField( - label: '단위', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadSelect( - initialValue: value, - onChanged: saving.value - ? null - : (next) { - uomNotifier.value = next; - uomError.value = null; - }, - options: _controller.uomOptions - .map( - (uom) => ShadOption( - value: uom.id, - child: Text(uom.uomName), - ), - ) - .toList(), - placeholder: const Text('단위를 선택하세요'), - selectedOptionBuilder: (context, selected) { - if (selected == null) { - return const Text('단위를 선택하세요'); - } - final uom = _controller.uomOptions - .firstWhere( - (u) => u.id == selected, - orElse: () => - Uom(id: selected, uomName: ''), - ); - return Text(uom.uomName); - }, ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: - materialTheme.colorScheme.error, + ], + ), + ); + }, + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: uomNotifier, + builder: (_, value, __) { + return ValueListenableBuilder( + valueListenable: uomError, + builder: (_, errorText, __) { + return _FormField( + label: '단위', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadSelect( + initialValue: value, + onChanged: saving.value + ? null + : (next) { + uomNotifier.value = next; + uomError.value = null; + }, + options: _controller.uomOptions + .map( + (uom) => ShadOption( + value: uom.id, + child: Text(uom.uomName), ), + ) + .toList(), + placeholder: const Text('단위를 선택하세요'), + selectedOptionBuilder: (context, selected) { + if (selected == null) { + return const Text('단위를 선택하세요'); + } + final uom = _controller.uomOptions + .firstWhere( + (u) => u.id == selected, + orElse: () => + Uom(id: selected, uomName: ''), + ); + return Text(uom.uomName); + }, + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, ), ), - ], - ), - ); - }, - ); - }, + ), + ], + ), + ); + }, + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: isActiveNotifier, + builder: (_, value, __) { + return _FormField( + label: '사용여부', + child: Row( + children: [ + ShadSwitch( + value: value, + onChanged: saving.value + ? null + : (next) => isActiveNotifier.value = next, + ), + const SizedBox(width: 8), + Text(value ? '사용' : '미사용'), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + _FormField( + label: '비고', + child: ShadTextarea(controller: noteController), + ), + if (isEdit) ...[ + const SizedBox(height: 20), + Text( + '생성일시: ${_formatDateTime(existing.createdAt)}', + style: theme.textTheme.small, ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: isActiveNotifier, - builder: (_, value, __) { - return _FormField( - label: '사용여부', - child: Row( - children: [ - ShadSwitch( - value: value, - onChanged: saving.value - ? null - : (next) => isActiveNotifier.value = next, - ), - const SizedBox(width: 8), - Text(value ? '사용' : '미사용'), - ], - ), - ); - }, + const SizedBox(height: 4), + Text( + '수정일시: ${_formatDateTime(existing.updatedAt)}', + style: theme.textTheme.small, ), - const SizedBox(height: 16), - _FormField( - label: '비고', - child: ShadTextarea(controller: noteController), - ), - if (isEdit) ...[ - const SizedBox(height: 20), - Text( - '생성일시: ${_formatDateTime(existing.createdAt)}', - style: theme.textTheme.small, - ), - const SizedBox(height: 4), - Text( - '수정일시: ${_formatDateTime(existing.updatedAt)}', - style: theme.textTheme.small, - ), - ], ], - ), + ], ), - ), - ), - ); - }, + ); + }, + ), + ), ); codeController.dispose(); @@ -747,24 +857,20 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { } Future _confirmDelete(Product product) async { - final confirmed = await showDialog( + final bool? confirmed = await SuperportDialog.show( context: context, - builder: (dialogContext) { - return AlertDialog( - title: const Text('제품 삭제'), - content: Text('"${product.productName}" 제품을 삭제하시겠습니까?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: const Text('취소'), - ), - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(true), - child: const Text('삭제'), - ), - ], - ); - }, + dialog: SuperportDialog( + title: '제품 삭제', + description: '"${product.productName}" 제품을 삭제하시겠습니까?', + primaryAction: ShadButton.destructive( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('삭제'), + ), + secondaryAction: ShadButton.ghost( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('취소'), + ), + ), ); if (confirmed == true && product.id != null) { @@ -813,43 +919,47 @@ class _ProductTable extends StatelessWidget { @override Widget build(BuildContext context) { - final header = [ - 'ID', - '제품코드', - '제품명', - '제조사', - '단위', - '사용', - '삭제', - '비고', - '변경일시', - '동작', - ].map((text) => ShadTableCell.header(child: Text(text))).toList(); + final columns = const [ + Text('ID'), + Text('제품코드'), + Text('제품명'), + Text('제조사'), + Text('단위'), + Text('사용'), + Text('삭제'), + Text('비고'), + Text('변경일시'), + Text('동작'), + ]; final rows = products.map((product) { - return [ - product.id?.toString() ?? '-', - product.productCode, - product.productName, - product.vendor?.vendorName ?? '-', - product.uom?.uomName ?? '-', - product.isActive ? 'Y' : 'N', - product.isDeleted ? 'Y' : '-', - product.note?.isEmpty ?? true ? '-' : product.note!, - product.updatedAt == null - ? '-' - : dateFormat.format(product.updatedAt!.toLocal()), - ].map((text) => ShadTableCell(child: Text(text))).toList()..add( + final cells = [ + Text(product.id?.toString() ?? '-'), + Text(product.productCode), + Text(product.productName), + Text(product.vendor?.vendorName ?? '-'), + Text(product.uom?.uomName ?? '-'), + Text(product.isActive ? 'Y' : 'N'), + Text(product.isDeleted ? 'Y' : '-'), + Text(product.note?.isEmpty ?? true ? '-' : product.note!), + Text( + product.updatedAt == null + ? '-' + : dateFormat.format(product.updatedAt!.toLocal()), + ), + ]; + + cells.add( ShadTableCell( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, + alignment: Alignment.centerRight, + child: Wrap( + spacing: 8, children: [ ShadButton.ghost( size: ShadButtonSize.sm, onPressed: onEdit == null ? null : () => onEdit!(product), child: const Icon(LucideIcons.pencil, size: 16), ), - const SizedBox(width: 8), product.isDeleted ? ShadButton.ghost( size: ShadButtonSize.sm, @@ -869,17 +979,18 @@ class _ProductTable extends StatelessWidget { ), ), ); + + return cells; }).toList(); - return SizedBox( - height: 56.0 * (products.length + 1), - child: ShadTable.list( - header: header, - children: rows, - columnSpanExtent: (index) => index == 9 - ? const FixedTableSpanExtent(160) - : const FixedTableSpanExtent(140), - ), + return SuperportTable( + columns: columns, + rows: rows, + rowHeight: 56, + maxHeight: 520, + columnSpanExtent: (index) => index == 9 + ? const FixedTableSpanExtent(160) + : const FixedTableSpanExtent(140), ); } } diff --git a/lib/features/masters/vendor/presentation/controllers/vendor_controller.dart b/lib/features/masters/vendor/presentation/controllers/vendor_controller.dart index 79b4d1b..93f72b5 100644 --- a/lib/features/masters/vendor/presentation/controllers/vendor_controller.dart +++ b/lib/features/masters/vendor/presentation/controllers/vendor_controller.dart @@ -11,6 +11,8 @@ enum VendorStatusFilter { all, activeOnly, inactiveOnly } /// - 목록/검색/필터/페이지 상태를 관리한다. /// - 생성/수정/삭제/복구 요청을 래핑하여 UI에 알린다. class VendorController extends ChangeNotifier { + static const int defaultPageSize = 20; + VendorController({required VendorRepository repository}) : _repository = repository; @@ -21,6 +23,7 @@ class VendorController extends ChangeNotifier { bool _isSubmitting = false; String _query = ''; VendorStatusFilter _statusFilter = VendorStatusFilter.all; + int _pageSize = defaultPageSize; String? _errorMessage; PaginatedResult? get result => _result; @@ -28,6 +31,7 @@ class VendorController extends ChangeNotifier { bool get isSubmitting => _isSubmitting; String get query => _query; VendorStatusFilter get statusFilter => _statusFilter; + int get pageSize => _pageSize; String? get errorMessage => _errorMessage; /// 목록 갱신 @@ -43,11 +47,14 @@ class VendorController extends ChangeNotifier { }; final response = await _repository.list( page: page, - pageSize: _result?.pageSize ?? 20, + pageSize: _pageSize, query: _query.isEmpty ? null : _query, isActive: isActive, ); _result = response; + if (response.pageSize > 0 && response.pageSize != _pageSize) { + _pageSize = response.pageSize; + } } catch (e) { _errorMessage = e.toString(); } finally { @@ -57,15 +64,29 @@ class VendorController extends ChangeNotifier { } void updateQuery(String value) { + if (_query == value) { + return; + } _query = value; notifyListeners(); } void updateStatusFilter(VendorStatusFilter filter) { + if (_statusFilter == filter) { + return; + } _statusFilter = filter; notifyListeners(); } + void updatePageSize(int size) { + if (size <= 0 || _pageSize == size) { + return; + } + _pageSize = size; + notifyListeners(); + } + /// 신규 등록 Future create(VendorInput input) async { _setSubmitting(true); diff --git a/lib/features/masters/vendor/presentation/pages/vendor_page.dart b/lib/features/masters/vendor/presentation/pages/vendor_page.dart index a08378b..0291388 100644 --- a/lib/features/masters/vendor/presentation/pages/vendor_page.dart +++ b/lib/features/masters/vendor/presentation/pages/vendor_page.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; +import 'package:superport_v2/widgets/components/superport_dialog.dart'; +import 'package:superport_v2/widgets/components/superport_table.dart'; import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; @@ -13,7 +16,9 @@ import '../../../vendor/domain/repositories/vendor_repository.dart'; import '../controllers/vendor_controller.dart'; class VendorPage extends StatelessWidget { - const VendorPage({super.key}); + const VendorPage({super.key, required this.routeUri}); + + final Uri routeUri; @override Widget build(BuildContext context) { @@ -58,12 +63,14 @@ class VendorPage extends StatelessWidget { ); } - return const _VendorEnabledPage(); + return _VendorEnabledPage(routeUri: routeUri); } } class _VendorEnabledPage extends StatefulWidget { - const _VendorEnabledPage(); + const _VendorEnabledPage({required this.routeUri}); + + final Uri routeUri; @override State<_VendorEnabledPage> createState() => _VendorEnabledPageState(); @@ -75,13 +82,22 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { final FocusNode _searchFocusNode = FocusNode(); final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm'); String? _lastError; + bool _routeApplied = false; @override void initState() { super.initState(); - _controller = VendorController(repository: GetIt.I()); - _controller.addListener(_onControllerChanged); - WidgetsBinding.instance.addPostFrameCallback((_) => _controller.fetch()); + _controller = VendorController(repository: GetIt.I()) + ..addListener(_onControllerChanged); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_routeApplied) { + _routeApplied = true; + _applyRouteParameters(); + } } @override @@ -140,6 +156,32 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { ), ], toolbar: FilterBar( + actions: [ + ShadButton.outline( + onPressed: _controller.isLoading ? null : _applyFilters, + child: const Text('검색 적용'), + ), + if (_searchController.text.isNotEmpty || + _controller.statusFilter != VendorStatusFilter.all) + ShadButton.ghost( + onPressed: _controller.isLoading + ? null + : () { + _searchController.clear(); + _searchFocusNode.requestFocus(); + _controller.updateQuery(''); + _controller.updateStatusFilter( + VendorStatusFilter.all, + ); + _updateRoute( + page: 1, + queryOverride: '', + statusOverride: VendorStatusFilter.all, + ); + }, + child: const Text('초기화'), + ), + ], children: [ SizedBox( width: 280, @@ -159,10 +201,9 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { selectedOptionBuilder: (context, value) => Text(_statusLabel(value)), onChanged: (value) { - if (value != null) { - _controller.updateStatusFilter(value); - _controller.fetch(page: 1); - } + if (value == null) return; + _controller.updateStatusFilter(value); + _updateRoute(page: 1, statusOverride: value); }, options: VendorStatusFilter.values .map( @@ -174,26 +215,6 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { .toList(), ), ), - ShadButton.outline( - onPressed: _controller.isLoading ? null : _applyFilters, - child: const Text('검색 적용'), - ), - if (_searchController.text.isNotEmpty || - _controller.statusFilter != VendorStatusFilter.all) - ShadButton.ghost( - onPressed: _controller.isLoading - ? null - : () { - _searchController.clear(); - _searchFocusNode.requestFocus(); - _controller.updateQuery(''); - _controller.updateStatusFilter( - VendorStatusFilter.all, - ); - _controller.fetch(page: 1); - }, - child: const Text('초기화'), - ), ], ), child: ShadCard( @@ -217,7 +238,7 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { size: ShadButtonSize.sm, onPressed: _controller.isLoading || currentPage <= 1 ? null - : () => _controller.fetch(page: currentPage - 1), + : () => _goToPage(currentPage - 1), child: const Text('이전'), ), const SizedBox(width: 8), @@ -225,7 +246,7 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { size: ShadButtonSize.sm, onPressed: _controller.isLoading || !hasNext ? null - : () => _controller.fetch(page: currentPage + 1), + : () => _goToPage(currentPage + 1), child: const Text('다음'), ), ], @@ -238,27 +259,22 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { child: Center(child: CircularProgressIndicator()), ) : vendors.isEmpty - ? Padding( - padding: const EdgeInsets.all(32), - child: Text( - '조건에 맞는 벤더가 없습니다.', - style: theme.textTheme.muted, - ), - ) - : _VendorTable( - vendors: vendors, - onEdit: _controller.isSubmitting - ? null - : (vendor) => - _openVendorForm(context, vendor: vendor), - onDelete: _controller.isSubmitting - ? null - : _confirmDelete, - onRestore: _controller.isSubmitting - ? null - : _restoreVendor, - dateFormat: _dateFormat, - ), + ? Padding( + padding: const EdgeInsets.all(32), + child: Text( + '조건에 맞는 벤더가 없습니다.', + style: theme.textTheme.muted, + ), + ) + : _VendorTable( + vendors: vendors, + onEdit: _controller.isSubmitting + ? null + : (vendor) => _openVendorForm(context, vendor: vendor), + onDelete: _controller.isSubmitting ? null : _confirmDelete, + onRestore: _controller.isSubmitting ? null : _restoreVendor, + dateFormat: _dateFormat, + ), ), ); }, @@ -266,8 +282,9 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { } void _applyFilters() { - _controller.updateQuery(_searchController.text.trim()); - _controller.fetch(page: 1); + final keyword = _searchController.text.trim(); + _controller.updateQuery(keyword); + _updateRoute(page: 1, queryOverride: keyword); } String _statusLabel(VendorStatusFilter filter) { @@ -281,6 +298,90 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { } } + void _applyRouteParameters() { + final params = widget.routeUri.queryParameters; + final query = params['q'] ?? ''; + final status = _statusFromParam(params['status']); + final pageSizeParam = int.tryParse(params['page_size'] ?? ''); + final pageParam = int.tryParse(params['page'] ?? ''); + + _searchController.text = query; + _controller.updateQuery(query); + _controller.updateStatusFilter(status); + if (pageSizeParam != null && pageSizeParam > 0) { + _controller.updatePageSize(pageSizeParam); + } + + final page = pageParam != null && pageParam > 0 ? pageParam : 1; + _controller.fetch(page: page); + } + + void _goToPage(int page) { + if (page < 1) { + page = 1; + } + _updateRoute(page: page); + } + + void _updateRoute({ + required int page, + String? queryOverride, + VendorStatusFilter? statusOverride, + int? pageSizeOverride, + }) { + final query = queryOverride ?? _controller.query; + final status = statusOverride ?? _controller.statusFilter; + final pageSize = pageSizeOverride ?? _controller.pageSize; + + final params = {}; + 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 _openVendorForm(BuildContext context, {Vendor? vendor}) async { final existingVendor = vendor; final isEdit = existingVendor != null; @@ -306,201 +407,183 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { final codeError = ValueNotifier(null); final nameError = ValueNotifier(null); - await showDialog( + await SuperportDialog.show( context: context, - builder: (dialogContext) { - final theme = ShadTheme.of(dialogContext); - final materialTheme = Theme.of(dialogContext); - final navigator = Navigator.of(dialogContext); - return Dialog( - insetPadding: const EdgeInsets.all(24), - clipBehavior: Clip.antiAlias, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 520), - child: ShadCard( - title: Text( - isEdit ? '벤더 수정' : '벤더 등록', - style: theme.textTheme.h3, - ), - description: Text( - '벤더 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.', - style: theme.textTheme.muted, - ), - footer: ValueListenableBuilder( - valueListenable: saving, - builder: (_, isSaving, __) { - return Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - ShadButton.ghost( - onPressed: isSaving ? null : () => navigator.pop(false), - child: const Text('취소'), - ), - const SizedBox(width: 12), - ShadButton( - onPressed: isSaving - ? null - : () async { - final code = codeController.text.trim(); - final name = nameController.text.trim(); - final note = noteController.text.trim(); + dialog: SuperportDialog( + title: isEdit ? '벤더 수정' : '벤더 등록', + description: '벤더 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.', + constraints: const BoxConstraints(maxWidth: 520), + primaryAction: ValueListenableBuilder( + valueListenable: saving, + builder: (context, isSaving, _) { + return ShadButton( + onPressed: isSaving + ? null + : () async { + final code = codeController.text.trim(); + final name = nameController.text.trim(); + final note = noteController.text.trim(); - codeError.value = code.isEmpty - ? '벤더코드를 입력하세요.' - : null; - nameError.value = name.isEmpty - ? '벤더명을 입력하세요.' - : null; + codeError.value = code.isEmpty ? '벤더코드를 입력하세요.' : null; + nameError.value = name.isEmpty ? '벤더명을 입력하세요.' : null; - if (codeError.value != null || - nameError.value != null) { - return; - } + if (codeError.value != null || nameError.value != null) { + return; + } - saving.value = true; - final input = VendorInput( - vendorCode: code, - vendorName: name, - isActive: isActiveNotifier.value, - note: note.isEmpty ? null : note, - ); - final response = isEdit - ? await _controller.update(vendorId!, input) - : await _controller.create(input); - saving.value = false; - if (response != null) { - if (!navigator.mounted) { - return; - } - if (mounted) { - _showSnack( - isEdit ? '벤더를 수정했습니다.' : '벤더를 등록했습니다.', - ); - } - navigator.pop(true); + saving.value = true; + final input = VendorInput( + vendorCode: code, + vendorName: name, + isActive: isActiveNotifier.value, + note: note.isEmpty ? null : note, + ); + final navigator = Navigator.of(context); + final response = isEdit + ? await _controller.update(vendorId!, input) + : await _controller.create(input); + saving.value = false; + if (response != null && mounted) { + if (!navigator.mounted) { + return; + } + _showSnack(isEdit ? '벤더를 수정했습니다.' : '벤더를 등록했습니다.'); + navigator.pop(true); + } + }, + child: Text(isEdit ? '저장' : '등록'), + ); + }, + ), + secondaryAction: ValueListenableBuilder( + valueListenable: saving, + builder: (context, isSaving, _) { + return ShadButton.ghost( + onPressed: isSaving + ? null + : () => Navigator.of(context).pop(false), + child: const Text('취소'), + ); + }, + ), + child: Builder( + builder: (dialogContext) { + final theme = ShadTheme.of(dialogContext); + final materialTheme = Theme.of(dialogContext); + return Padding( + padding: const EdgeInsets.only(right: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ValueListenableBuilder( + valueListenable: codeError, + builder: (_, errorText, __) { + return _FormField( + label: '벤더코드', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: codeController, + readOnly: isEdit, + onChanged: (_) { + if (codeController.text.trim().isNotEmpty) { + codeError.value = null; } }, - child: Text(isEdit ? '저장' : '등록'), - ), - ], - ); - }, - ), - child: Padding( - padding: const EdgeInsets.only(right: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - ValueListenableBuilder( - valueListenable: codeError, - builder: (_, errorText, __) { - return _FormField( - label: '벤더코드', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - controller: codeController, - readOnly: isEdit, - onChanged: (_) { - if (codeController.text.trim().isNotEmpty) { - codeError.value = null; - } - }, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, ), ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: nameError, - builder: (_, errorText, __) { - return _FormField( - label: '벤더명', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - controller: nameController, - onChanged: (_) { - if (nameController.text.trim().isNotEmpty) { - nameError.value = null; - } - }, ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: nameError, + builder: (_, errorText, __) { + return _FormField( + label: '벤더명', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: nameController, + onChanged: (_) { + if (nameController.text.trim().isNotEmpty) { + nameError.value = null; + } + }, + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, ), ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: isActiveNotifier, - builder: (_, value, __) { - return _FormField( - label: '사용여부', - child: Row( - children: [ - ShadSwitch( - value: value, - onChanged: saving.value - ? null - : (next) => isActiveNotifier.value = next, ), - const SizedBox(width: 8), - Text(value ? '사용' : '미사용'), - ], - ), - ); - }, + ], + ), + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: isActiveNotifier, + builder: (_, value, __) { + return _FormField( + label: '사용여부', + child: Row( + children: [ + ShadSwitch( + value: value, + onChanged: saving.value + ? null + : (next) => isActiveNotifier.value = next, + ), + const SizedBox(width: 8), + Text(value ? '사용' : '미사용'), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + _FormField( + label: '비고', + child: ShadTextarea(controller: noteController), + ), + if (isEdit) ...[ + const SizedBox(height: 20), + Text( + '생성일시: ${_formatDateTime(existingVendor.createdAt)}', + style: theme.textTheme.small, ), - const SizedBox(height: 16), - _FormField( - label: '비고', - child: ShadTextarea(controller: noteController), + const SizedBox(height: 4), + Text( + '수정일시: ${_formatDateTime(existingVendor.updatedAt)}', + style: theme.textTheme.small, ), - if (isEdit) ...[ - const SizedBox(height: 20), - Text( - '생성일시: ${_formatDateTime(existingVendor.createdAt)}', - style: theme.textTheme.small, - ), - const SizedBox(height: 4), - Text( - '수정일시: ${_formatDateTime(existingVendor.updatedAt)}', - style: theme.textTheme.small, - ), - ], ], - ), + ], ), - ), - ), - ); - }, + ); + }, + ), + ), ); codeController.dispose(); @@ -513,24 +596,22 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { } Future _confirmDelete(Vendor vendor) async { - final confirmed = await showDialog( + final confirmed = await SuperportDialog.show( context: context, - builder: (dialogContext) { - return AlertDialog( - title: const Text('벤더 삭제'), - content: Text('"${vendor.vendorName}" 벤더를 삭제하시겠습니까?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: const Text('취소'), - ), - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(true), - child: const Text('삭제'), - ), - ], - ); - }, + dialog: SuperportDialog( + title: '벤더 삭제', + description: '"${vendor.vendorName}" 벤더를 삭제하시겠습니까?', + actions: [ + ShadButton.ghost( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('취소'), + ), + ShadButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('삭제'), + ), + ], + ), ); if (confirmed == true && vendor.id != null) { @@ -581,39 +662,43 @@ class _VendorTable extends StatelessWidget { @override Widget build(BuildContext context) { - final header = [ - 'ID', - '벤더코드', - '벤더명', - '사용', - '삭제', - '비고', - '변경일시', - '동작', - ].map((text) => ShadTableCell.header(child: Text(text))).toList(); + final columns = const [ + Text('ID'), + Text('벤더코드'), + Text('벤더명'), + Text('사용'), + Text('삭제'), + Text('비고'), + Text('변경일시'), + Text('동작'), + ]; final rows = vendors.map((vendor) { - return [ - vendor.id?.toString() ?? '-', - vendor.vendorCode, - vendor.vendorName, - vendor.isActive ? 'Y' : 'N', - vendor.isDeleted ? 'Y' : '-', - vendor.note?.isEmpty ?? true ? '-' : vendor.note!, - vendor.updatedAt == null - ? '-' - : dateFormat.format(vendor.updatedAt!.toLocal()), - ].map((text) => ShadTableCell(child: Text(text))).toList()..add( + final cells = [ + Text(vendor.id?.toString() ?? '-'), + Text(vendor.vendorCode), + Text(vendor.vendorName), + Text(vendor.isActive ? 'Y' : 'N'), + Text(vendor.isDeleted ? 'Y' : '-'), + Text(vendor.note?.isEmpty ?? true ? '-' : vendor.note!), + Text( + vendor.updatedAt == null + ? '-' + : dateFormat.format(vendor.updatedAt!.toLocal()), + ), + ]; + + cells.add( ShadTableCell( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, + alignment: Alignment.centerRight, + child: Wrap( + spacing: 8, children: [ ShadButton.ghost( size: ShadButtonSize.sm, onPressed: onEdit == null ? null : () => onEdit!(vendor), child: const Icon(LucideIcons.pencil, size: 16), ), - const SizedBox(width: 8), vendor.isDeleted ? ShadButton.ghost( size: ShadButtonSize.sm, @@ -633,17 +718,18 @@ class _VendorTable extends StatelessWidget { ), ), ); + + return cells; }).toList(); - return SizedBox( - height: 56.0 * (vendors.length + 1), - child: ShadTable.list( - header: header, - children: rows, - columnSpanExtent: (index) => index == 7 - ? const FixedTableSpanExtent(160) - : const FixedTableSpanExtent(140), - ), + return SuperportTable( + columns: columns, + rows: rows, + rowHeight: 56, + maxHeight: 520, + columnSpanExtent: (index) => index == 7 + ? const FixedTableSpanExtent(160) + : const FixedTableSpanExtent(140), ); } } diff --git a/lib/features/masters/warehouse/presentation/controllers/warehouse_controller.dart b/lib/features/masters/warehouse/presentation/controllers/warehouse_controller.dart index 6d21781..506ba0f 100644 --- a/lib/features/masters/warehouse/presentation/controllers/warehouse_controller.dart +++ b/lib/features/masters/warehouse/presentation/controllers/warehouse_controller.dart @@ -7,6 +7,8 @@ import '../../domain/repositories/warehouse_repository.dart'; enum WarehouseStatusFilter { all, activeOnly, inactiveOnly } class WarehouseController extends ChangeNotifier { + static const int defaultPageSize = 20; + WarehouseController({required WarehouseRepository repository}) : _repository = repository; @@ -17,6 +19,7 @@ class WarehouseController extends ChangeNotifier { bool _isSubmitting = false; String _query = ''; WarehouseStatusFilter _statusFilter = WarehouseStatusFilter.all; + int _pageSize = defaultPageSize; String? _errorMessage; PaginatedResult? get result => _result; @@ -24,6 +27,7 @@ class WarehouseController extends ChangeNotifier { bool get isSubmitting => _isSubmitting; String get query => _query; WarehouseStatusFilter get statusFilter => _statusFilter; + int get pageSize => _pageSize; String? get errorMessage => _errorMessage; Future fetch({int page = 1}) async { @@ -38,11 +42,14 @@ class WarehouseController extends ChangeNotifier { }; final response = await _repository.list( page: page, - pageSize: _result?.pageSize ?? 20, + pageSize: _pageSize, query: _query.isEmpty ? null : _query, isActive: isActive, ); _result = response; + if (response.pageSize > 0 && response.pageSize != _pageSize) { + _pageSize = response.pageSize; + } } catch (e) { _errorMessage = e.toString(); } finally { @@ -52,15 +59,29 @@ class WarehouseController extends ChangeNotifier { } void updateQuery(String value) { + if (_query == value) { + return; + } _query = value; notifyListeners(); } void updateStatusFilter(WarehouseStatusFilter filter) { + if (_statusFilter == filter) { + return; + } _statusFilter = filter; notifyListeners(); } + void updatePageSize(int size) { + if (size <= 0 || _pageSize == size) { + return; + } + _pageSize = size; + notifyListeners(); + } + Future create(WarehouseInput input) async { _setSubmitting(true); try { diff --git a/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart b/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart index 651304c..10231ea 100644 --- a/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart +++ b/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; +import 'package:superport_v2/widgets/components/superport_dialog.dart'; +import 'package:superport_v2/widgets/components/superport_table.dart'; import 'package:superport_v2/features/util/postal_search/presentation/models/postal_search_result.dart'; import 'package:superport_v2/features/util/postal_search/presentation/widgets/postal_search_dialog.dart'; @@ -15,7 +18,9 @@ import '../../domain/repositories/warehouse_repository.dart'; import '../controllers/warehouse_controller.dart'; class WarehousePage extends StatelessWidget { - const WarehousePage({super.key}); + const WarehousePage({super.key, required this.routeUri}); + + final Uri routeUri; @override Widget build(BuildContext context) { @@ -72,12 +77,14 @@ class WarehousePage extends StatelessWidget { ); } - return const _WarehouseEnabledPage(); + return _WarehouseEnabledPage(routeUri: routeUri); } } class _WarehouseEnabledPage extends StatefulWidget { - const _WarehouseEnabledPage(); + const _WarehouseEnabledPage({required this.routeUri}); + + final Uri routeUri; @override State<_WarehouseEnabledPage> createState() => _WarehouseEnabledPageState(); @@ -89,6 +96,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { final FocusNode _searchFocus = FocusNode(); final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm'); String? _lastError; + bool _routeApplied = false; @override void initState() { @@ -96,9 +104,15 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { _controller = WarehouseController( repository: GetIt.I(), )..addListener(_handleControllerUpdate); - WidgetsBinding.instance.addPostFrameCallback((_) { - _controller.fetch(); - }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_routeApplied) { + _routeApplied = true; + _applyRouteParameters(); + } } void _handleControllerUpdate() { @@ -161,6 +175,31 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { ), ], toolbar: FilterBar( + actions: [ + ShadButton.outline( + onPressed: _controller.isLoading ? null : _applyFilters, + child: const Text('검색 적용'), + ), + if (showReset) + ShadButton.ghost( + onPressed: _controller.isLoading + ? null + : () { + _searchController.clear(); + _searchFocus.requestFocus(); + _controller.updateQuery(''); + _controller.updateStatusFilter( + WarehouseStatusFilter.all, + ); + _updateRoute( + page: 1, + queryOverride: '', + statusOverride: WarehouseStatusFilter.all, + ); + }, + child: const Text('초기화'), + ), + ], children: [ SizedBox( width: 260, @@ -182,6 +221,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { onChanged: (value) { if (value == null) return; _controller.updateStatusFilter(value); + _updateRoute(page: 1, statusOverride: value); }, options: WarehouseStatusFilter.values .map( @@ -193,25 +233,6 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { .toList(), ), ), - ShadButton.outline( - onPressed: _controller.isLoading ? null : _applyFilters, - child: const Text('검색 적용'), - ), - if (showReset) - ShadButton.ghost( - onPressed: _controller.isLoading - ? null - : () { - _searchController.clear(); - _searchFocus.requestFocus(); - _controller.updateQuery(''); - _controller.updateStatusFilter( - WarehouseStatusFilter.all, - ); - _controller.fetch(page: 1); - }, - child: const Text('초기화'), - ), ], ), child: ShadCard( @@ -235,7 +256,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { size: ShadButtonSize.sm, onPressed: _controller.isLoading || currentPage <= 1 ? null - : () => _controller.fetch(page: currentPage - 1), + : () => _goToPage(currentPage - 1), child: const Text('이전'), ), const SizedBox(width: 8), @@ -243,7 +264,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { size: ShadButtonSize.sm, onPressed: _controller.isLoading || !hasNext ? null - : () => _controller.fetch(page: currentPage + 1), + : () => _goToPage(currentPage + 1), child: const Text('다음'), ), ], @@ -282,8 +303,9 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { } void _applyFilters() { - _controller.updateQuery(_searchController.text.trim()); - _controller.fetch(page: 1); + final keyword = _searchController.text.trim(); + _controller.updateQuery(keyword); + _updateRoute(page: 1, queryOverride: keyword); } String _statusLabel(WarehouseStatusFilter filter) { @@ -297,6 +319,90 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { } } + void _applyRouteParameters() { + final params = widget.routeUri.queryParameters; + final query = params['q'] ?? ''; + final status = _statusFromParam(params['status']); + final pageSizeParam = int.tryParse(params['page_size'] ?? ''); + final pageParam = int.tryParse(params['page'] ?? ''); + + _searchController.text = query; + _controller.updateQuery(query); + _controller.updateStatusFilter(status); + if (pageSizeParam != null && pageSizeParam > 0) { + _controller.updatePageSize(pageSizeParam); + } + + final page = pageParam != null && pageParam > 0 ? pageParam : 1; + _controller.fetch(page: page); + } + + void _goToPage(int page) { + if (page < 1) { + page = 1; + } + _updateRoute(page: page); + } + + void _updateRoute({ + required int page, + String? queryOverride, + WarehouseStatusFilter? statusOverride, + int? pageSizeOverride, + }) { + final query = queryOverride ?? _controller.query; + final status = statusOverride ?? _controller.statusFilter; + final pageSize = pageSizeOverride ?? _controller.pageSize; + + final params = {}; + 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 _openWarehouseForm( BuildContext context, { Warehouse? warehouse, @@ -337,293 +443,336 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { final saving = ValueNotifier(false); final codeError = ValueNotifier(null); final nameError = ValueNotifier(null); + final zipcodeError = ValueNotifier(null); - await showDialog( - context: context, - builder: (dialogContext) { - final theme = ShadTheme.of(dialogContext); - final materialTheme = Theme.of(dialogContext); - final navigator = Navigator.of(dialogContext); - Future openPostalSearch() async { - final keyword = zipcodeController.text.trim(); - final result = await showPostalSearchDialog( - dialogContext, - initialKeyword: keyword.isEmpty ? null : keyword, - ); - if (result == null) { - return; - } - zipcodeController - ..text = result.zipcode - ..selection = TextSelection.collapsed( - offset: result.zipcode.length, - ); - selectedPostalNotifier.value = result; - if (result.fullAddress.isNotEmpty) { - addressController - ..text = result.fullAddress - ..selection = TextSelection.collapsed( - offset: addressController.text.length, - ); - } + 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; + } + } - return Dialog( - insetPadding: const EdgeInsets.all(24), - clipBehavior: Clip.antiAlias, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 540), - child: ShadCard( - title: Text( - isEdit ? '창고 수정' : '창고 등록', - style: theme.textTheme.h3, - ), - description: Text( - '창고 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.', - style: theme.textTheme.muted, - ), - footer: ValueListenableBuilder( - 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(); + void handlePostalSelectionChange() { + if (selectedPostalNotifier.value != null) { + zipcodeError.value = null; + } + } - codeError.value = code.isEmpty - ? '창고코드를 입력하세요.' - : null; - nameError.value = name.isEmpty - ? '창고명을 입력하세요.' - : null; + zipcodeController.addListener(handleZipcodeChange); + selectedPostalNotifier.addListener(handlePostalSelectionChange); - if (codeError.value != null || - nameError.value != null) { - return; - } + await SuperportDialog.show( + context: context, + dialog: SuperportDialog( + title: isEdit ? '창고 수정' : '창고 등록', + description: '창고 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.', + constraints: const BoxConstraints(maxWidth: 540), + primaryAction: ValueListenableBuilder( + 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; - 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); + 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( + 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); + + Future openPostalSearch() async { + final keyword = zipcodeController.text.trim(); + final result = await showPostalSearchDialog( + dialogContext, + initialKeyword: keyword.isEmpty ? null : keyword, + ); + if (result == null) { + return; + } + isApplyingPostalSelection = true; + zipcodeController + ..text = result.zipcode + ..selection = TextSelection.collapsed( + offset: result.zipcode.length, + ); + isApplyingPostalSelection = false; + selectedPostalNotifier.value = result; + if (result.fullAddress.isNotEmpty) { + addressController + ..text = result.fullAddress + ..selection = TextSelection.collapsed( + offset: addressController.text.length, + ); + } + } + + return SingleChildScrollView( + padding: const EdgeInsets.only(right: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ValueListenableBuilder( + valueListenable: codeError, + builder: (_, errorText, __) { + return _FormField( + label: '창고코드', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: codeController, + readOnly: isEdit, + onChanged: (_) { + if (codeController.text.trim().isNotEmpty) { + codeError.value = null; } }, - child: Text(isEdit ? '저장' : '등록'), - ), - ], - ); - }, - ), - child: SingleChildScrollView( - padding: const EdgeInsets.only(right: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - ValueListenableBuilder( - valueListenable: codeError, - builder: (_, errorText, __) { - return _FormField( - label: '창고코드', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - controller: codeController, - readOnly: isEdit, - onChanged: (_) { - if (codeController.text.trim().isNotEmpty) { - codeError.value = null; - } - }, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: nameError, - builder: (_, errorText, __) { - return _FormField( - label: '창고명', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - controller: nameController, - onChanged: (_) { - if (nameController.text.trim().isNotEmpty) { - nameError.value = null; - } - }, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - _FormField( - label: '우편번호', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: ShadInput( - controller: zipcodeController, - placeholder: const Text('예: 06000'), - keyboardType: TextInputType.number, - ), - ), - const SizedBox(width: 8), - ShadButton.outline( - onPressed: saving.value - ? null - : openPostalSearch, - child: const Text('검색'), - ), - ], - ), - const SizedBox(height: 8), - ValueListenableBuilder( - valueListenable: selectedPostalNotifier, - builder: (_, selection, __) { - if (selection == null) { - return Text( - '검색 버튼을 눌러 주소를 선택하세요.', + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, style: theme.textTheme.small.copyWith( - color: theme.colorScheme.mutedForeground, + color: materialTheme.colorScheme.error, ), - ); - } - final fullAddress = selection.fullAddress; - return Text( - fullAddress.isEmpty - ? '선택한 우편번호에 주소 정보가 없습니다.' - : fullAddress, - style: theme.textTheme.small, - ); - }, - ), - ], - ), - ), - const SizedBox(height: 16), - _FormField( - label: '상세주소', - child: ShadInput( - controller: addressController, - placeholder: const Text('상세주소 입력'), - ), - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: isActiveNotifier, - builder: (_, value, __) { - return _FormField( - label: '사용여부', - child: Row( - children: [ - ShadSwitch( - value: value, - onChanged: saving.value - ? null - : (next) => isActiveNotifier.value = next, + ), ), - const SizedBox(width: 8), - Text(value ? '사용' : '미사용'), - ], - ), - ); - }, + ], + ), + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: nameError, + builder: (_, errorText, __) { + return _FormField( + label: '창고명', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: nameController, + onChanged: (_) { + if (nameController.text.trim().isNotEmpty) { + nameError.value = null; + } + }, + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: zipcodeError, + builder: (_, zipcodeErrorText, __) { + return _FormField( + label: '우편번호', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: ShadInput( + controller: zipcodeController, + placeholder: const Text('예: 06000'), + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 8), + ValueListenableBuilder( + valueListenable: saving, + builder: (_, isSaving, __) { + return ShadButton.outline( + onPressed: isSaving + ? null + : openPostalSearch, + child: const Text('검색'), + ); + }, + ), + ], + ), + const SizedBox(height: 8), + ValueListenableBuilder( + valueListenable: selectedPostalNotifier, + builder: (_, selection, __) { + if (selection == null) { + return Text( + '검색 버튼을 눌러 주소를 선택하세요.', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ); + } + final fullAddress = selection.fullAddress; + return Text( + fullAddress.isEmpty + ? '선택한 우편번호에 주소 정보가 없습니다.' + : fullAddress, + style: theme.textTheme.small, + ); + }, + ), + if (zipcodeErrorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + zipcodeErrorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + _FormField( + label: '상세주소', + child: ShadInput( + controller: addressController, + placeholder: const Text('상세주소 입력'), ), - const SizedBox(height: 16), - _FormField( - label: '비고', - child: ShadTextarea(controller: noteController), + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: isActiveNotifier, + builder: (_, value, __) { + return _FormField( + label: '사용여부', + child: Row( + children: [ + ShadSwitch( + value: value, + onChanged: saving.value + ? null + : (next) => isActiveNotifier.value = next, + ), + const SizedBox(width: 8), + Text(value ? '사용' : '미사용'), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + _FormField( + label: '비고', + child: ShadTextarea(controller: noteController), + ), + if (isEdit) ...[ + const SizedBox(height: 20), + Text( + '생성일시: ${_formatDateTime(existing.createdAt)}', + style: theme.textTheme.small, + ), + const SizedBox(height: 4), + Text( + '수정일시: ${_formatDateTime(existing.updatedAt)}', + style: theme.textTheme.small, ), - if (isEdit) ...[ - const SizedBox(height: 20), - Text( - '생성일시: ${_formatDateTime(existing.createdAt)}', - style: theme.textTheme.small, - ), - const SizedBox(height: 4), - Text( - '수정일시: ${_formatDateTime(existing.updatedAt)}', - style: theme.textTheme.small, - ), - ], ], - ), + ], ), - ), - ), - ); - }, + ); + }, + ), + ), ); + zipcodeController.removeListener(handleZipcodeChange); + selectedPostalNotifier.removeListener(handlePostalSelectionChange); + if (!mounted) { codeController.dispose(); nameController.dispose(); @@ -635,6 +784,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { saving.dispose(); codeError.dispose(); nameError.dispose(); + zipcodeError.dispose(); return; } @@ -648,27 +798,26 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { saving.dispose(); codeError.dispose(); nameError.dispose(); + zipcodeError.dispose(); } Future _confirmDelete(Warehouse warehouse) async { - final confirmed = await showDialog( + final confirmed = await SuperportDialog.show( context: context, - builder: (dialogContext) { - return AlertDialog( - title: const Text('창고 삭제'), - content: Text('"${warehouse.warehouseName}" 창고를 삭제하시겠습니까?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: const Text('취소'), - ), - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(true), - child: const Text('삭제'), - ), - ], - ); - }, + dialog: SuperportDialog( + title: '창고 삭제', + description: '"${warehouse.warehouseName}" 창고를 삭제하시겠습니까?', + actions: [ + ShadButton.ghost( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('취소'), + ), + ShadButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('삭제'), + ), + ], + ), ); if (confirmed == true && warehouse.id != null) { @@ -717,45 +866,51 @@ class _WarehouseTable extends StatelessWidget { @override Widget build(BuildContext context) { - final header = [ - 'ID', - '창고코드', - '창고명', - '우편번호', - '상세주소', - '사용', - '삭제', - '비고', - '변경일시', - '동작', - ].map((text) => ShadTableCell.header(child: Text(text))).toList(); + final columns = const [ + Text('ID'), + Text('창고코드'), + Text('창고명'), + Text('우편번호'), + Text('상세주소'), + Text('사용'), + Text('삭제'), + Text('비고'), + Text('변경일시'), + Text('동작'), + ]; final rows = warehouses.map((warehouse) { - return [ - warehouse.id?.toString() ?? '-', - warehouse.warehouseCode, - warehouse.warehouseName, - warehouse.zipcode?.zipcode ?? '-', - warehouse.addressDetail?.isEmpty ?? true - ? '-' - : warehouse.addressDetail!, - warehouse.isActive ? 'Y' : 'N', - warehouse.isDeleted ? 'Y' : '-', - warehouse.note?.isEmpty ?? true ? '-' : warehouse.note!, - warehouse.updatedAt == null - ? '-' - : dateFormat.format(warehouse.updatedAt!.toLocal()), - ].map((text) => ShadTableCell(child: Text(text))).toList()..add( + final cells = [ + Text(warehouse.id?.toString() ?? '-'), + Text(warehouse.warehouseCode), + Text(warehouse.warehouseName), + Text(warehouse.zipcode?.zipcode ?? '-'), + Text( + warehouse.addressDetail?.isEmpty ?? true + ? '-' + : warehouse.addressDetail!, + ), + Text(warehouse.isActive ? 'Y' : 'N'), + Text(warehouse.isDeleted ? 'Y' : '-'), + Text(warehouse.note?.isEmpty ?? true ? '-' : warehouse.note!), + Text( + warehouse.updatedAt == null + ? '-' + : dateFormat.format(warehouse.updatedAt!.toLocal()), + ), + ]; + + cells.add( ShadTableCell( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, + alignment: Alignment.centerRight, + child: Wrap( + spacing: 8, children: [ ShadButton.ghost( size: ShadButtonSize.sm, onPressed: onEdit == null ? null : () => onEdit!(warehouse), child: const Icon(LucideIcons.pencil, size: 16), ), - const SizedBox(width: 8), warehouse.isDeleted ? ShadButton.ghost( size: ShadButtonSize.sm, @@ -775,17 +930,18 @@ class _WarehouseTable extends StatelessWidget { ), ), ); + + return cells; }).toList(); - return SizedBox( - height: 56.0 * (warehouses.length + 1), - child: ShadTable.list( - header: header, - children: rows, - columnSpanExtent: (index) => index == 9 - ? const FixedTableSpanExtent(160) - : const FixedTableSpanExtent(140), - ), + return SuperportTable( + columns: columns, + rows: rows, + rowHeight: 56, + maxHeight: 520, + columnSpanExtent: (index) => index == 9 + ? const FixedTableSpanExtent(160) + : const FixedTableSpanExtent(140), ); } } diff --git a/lib/features/reporting/presentation/pages/reporting_page.dart b/lib/features/reporting/presentation/pages/reporting_page.dart index c762138..d7db326 100644 --- a/lib/features/reporting/presentation/pages/reporting_page.dart +++ b/lib/features/reporting/presentation/pages/reporting_page.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/constants/app_sections.dart'; @@ -7,7 +9,9 @@ import 'package:superport_v2/features/masters/warehouse/domain/entities/warehous import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/empty_state.dart'; +import 'package:superport_v2/widgets/components/feedback.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; +import 'package:superport_v2/widgets/components/superport_date_picker.dart'; class ReportingPage extends StatefulWidget { const ReportingPage({super.key}); @@ -18,12 +22,16 @@ class ReportingPage extends StatefulWidget { class _ReportingPageState extends State { late final WarehouseRepository _warehouseRepository; - final DateFormat _dateFormat = DateFormat('yyyy.MM.dd'); + final intl.DateFormat _dateFormat = intl.DateFormat('yyyy.MM.dd'); - DateTimeRange? _dateRange; - ReportTypeFilter _selectedType = ReportTypeFilter.all; - ReportStatusFilter _selectedStatus = ReportStatusFilter.all; - WarehouseFilterOption _selectedWarehouse = WarehouseFilterOption.all; + DateTimeRange? _appliedDateRange; + DateTimeRange? _pendingDateRange; + ReportTypeFilter _appliedType = ReportTypeFilter.all; + ReportTypeFilter _pendingType = ReportTypeFilter.all; + ReportStatusFilter _appliedStatus = ReportStatusFilter.all; + ReportStatusFilter _pendingStatus = ReportStatusFilter.all; + WarehouseFilterOption _appliedWarehouse = WarehouseFilterOption.all; + WarehouseFilterOption _pendingWarehouse = WarehouseFilterOption.all; List _warehouseOptions = const [ WarehouseFilterOption.all, @@ -63,14 +71,14 @@ class _ReportingPageState extends State { if (mounted) { setState(() { _warehouseOptions = options; - WarehouseFilterOption nextSelected = WarehouseFilterOption.all; - for (final option in options) { - if (option == _selectedWarehouse) { - nextSelected = option; - break; - } - } - _selectedWarehouse = nextSelected; + _appliedWarehouse = _resolveWarehouseOption( + _appliedWarehouse, + options, + ); + _pendingWarehouse = _resolveWarehouseOption( + _pendingWarehouse, + options, + ); }); } } catch (error) { @@ -78,7 +86,8 @@ class _ReportingPageState extends State { setState(() { _warehouseError = '창고 목록을 불러오지 못했습니다. 잠시 후 다시 시도하세요.'; _warehouseOptions = const [WarehouseFilterOption.all]; - _selectedWarehouse = WarehouseFilterOption.all; + _appliedWarehouse = WarehouseFilterOption.all; + _pendingWarehouse = WarehouseFilterOption.all; }); } } finally { @@ -90,57 +99,70 @@ class _ReportingPageState extends State { } } - Future _pickDateRange() async { - final now = DateTime.now(); - final initialRange = - _dateRange ?? - DateTimeRange( - start: DateTime( - now.year, - now.month, - now.day, - ).subtract(const Duration(days: 6)), - end: DateTime(now.year, now.month, now.day), - ); - final picked = await showDateRangePicker( - context: context, - initialDateRange: initialRange, - firstDate: DateTime(now.year - 5), - lastDate: DateTime(now.year + 2), - helpText: '기간 선택', - saveText: '적용', - currentDate: now, - locale: Localizations.localeOf(context), - ); - if (picked != null && mounted) { - setState(() { - _dateRange = picked; - }); - } - } - void _resetFilters() { setState(() { - _dateRange = null; - _selectedType = ReportTypeFilter.all; - _selectedStatus = ReportStatusFilter.all; - _selectedWarehouse = WarehouseFilterOption.all; + _appliedDateRange = null; + _pendingDateRange = null; + _appliedType = ReportTypeFilter.all; + _pendingType = ReportTypeFilter.all; + _appliedStatus = ReportStatusFilter.all; + _pendingStatus = ReportStatusFilter.all; + _appliedWarehouse = WarehouseFilterOption.all; + _pendingWarehouse = WarehouseFilterOption.all; + }); + } + + void _applyFilters() { + setState(() { + _appliedDateRange = _pendingDateRange; + _appliedType = _pendingType; + _appliedStatus = _pendingStatus; + _appliedWarehouse = _pendingWarehouse; }); } bool get _canExport { - return _dateRange != null && _selectedType != ReportTypeFilter.all; + return _appliedDateRange != null && _appliedType != ReportTypeFilter.all; } bool get _hasCustomFilters { - return _dateRange != null || - _selectedType != ReportTypeFilter.all || - _selectedStatus != ReportStatusFilter.all || - _selectedWarehouse != WarehouseFilterOption.all; + return _appliedDateRange != null || + _appliedType != ReportTypeFilter.all || + _appliedStatus != ReportStatusFilter.all || + _appliedWarehouse != WarehouseFilterOption.all; } - String get _dateRangeLabel { - final range = _dateRange; + bool get _hasAppliedFilters => _hasCustomFilters; + + bool get _hasDirtyFilters => + !_isSameRange(_pendingDateRange, _appliedDateRange) || + _pendingType != _appliedType || + _pendingStatus != _appliedStatus || + _pendingWarehouse != _appliedWarehouse; + + bool _isSameRange(DateTimeRange? a, DateTimeRange? b) { + if (identical(a, b)) { + return true; + } + if (a == null || b == null) { + return a == b; + } + return a.start == b.start && a.end == b.end; + } + + WarehouseFilterOption _resolveWarehouseOption( + WarehouseFilterOption target, + List options, + ) { + for (final option in options) { + if (option == target) { + return option; + } + } + return options.first; + } + + String _dateRangeLabel(DateTimeRange? range) { if (range == null) { return '기간 선택'; } @@ -150,11 +172,7 @@ class _ReportingPageState extends State { String _formatDate(DateTime value) => _dateFormat.format(value); void _handleExport(ReportExportFormat format) { - final messenger = ScaffoldMessenger.of(context); - messenger.clearSnackBars(); - messenger.showSnackBar( - SnackBar(content: Text('${format.label} 다운로드 연동은 준비 중입니다.')), - ); + SuperportToast.info(context, '${format.label} 다운로드 연동은 준비 중입니다.'); } @override @@ -174,34 +192,56 @@ class _ReportingPageState extends State { onPressed: _canExport ? () => _handleExport(ReportExportFormat.xlsx) : null, - leading: const Icon(LucideIcons.fileDown, size: 16), + leading: const Icon(lucide.LucideIcons.fileDown, size: 16), child: const Text('XLSX 다운로드'), ), ShadButton.outline( onPressed: _canExport ? () => _handleExport(ReportExportFormat.pdf) : null, - leading: const Icon(LucideIcons.fileText, size: 16), + leading: const Icon(lucide.LucideIcons.fileText, size: 16), child: const Text('PDF 다운로드'), ), ], toolbar: FilterBar( + actionConfig: FilterBarActionConfig( + onApply: _applyFilters, + onReset: _resetFilters, + hasPendingChanges: _hasDirtyFilters, + hasActiveFilters: _hasAppliedFilters, + ), children: [ - ShadButton.outline( - onPressed: _pickDateRange, - leading: const Icon(LucideIcons.calendar, size: 16), - child: Text(_dateRangeLabel), + SizedBox( + width: 220, + child: SuperportDateRangePickerButton( + value: _pendingDateRange ?? _appliedDateRange, + dateFormat: _dateFormat, + firstDate: DateTime(DateTime.now().year - 5), + lastDate: DateTime(DateTime.now().year + 2), + initialDateRange: + _pendingDateRange ?? + _appliedDateRange ?? + DateTimeRange( + start: DateTime.now().subtract(const Duration(days: 6)), + end: DateTime.now(), + ), + onChanged: (range) { + setState(() { + _pendingDateRange = range; + }); + }, + ), ), SizedBox( width: 200, child: ShadSelect( - key: ValueKey(_selectedType), - initialValue: _selectedType, + key: ValueKey(_pendingType), + initialValue: _pendingType, selectedOptionBuilder: (_, value) => Text(value.label), onChanged: (value) { if (value == null) return; setState(() { - _selectedType = value; + _pendingType = value; }); }, options: [ @@ -214,14 +254,14 @@ class _ReportingPageState extends State { width: 220, child: ShadSelect( key: ValueKey( - '${_selectedWarehouse.cacheKey}-${_warehouseOptions.length}', + '${_pendingWarehouse.cacheKey}-${_warehouseOptions.length}', ), - initialValue: _selectedWarehouse, + initialValue: _pendingWarehouse, selectedOptionBuilder: (_, value) => Text(value.label), onChanged: (value) { if (value == null) return; setState(() { - _selectedWarehouse = value; + _pendingWarehouse = value; }); }, options: [ @@ -233,13 +273,13 @@ class _ReportingPageState extends State { SizedBox( width: 200, child: ShadSelect( - key: ValueKey(_selectedStatus), - initialValue: _selectedStatus, + key: ValueKey(_pendingStatus), + initialValue: _pendingStatus, selectedOptionBuilder: (_, value) => Text(value.label), onChanged: (value) { if (value == null) return; setState(() { - _selectedStatus = value; + _pendingStatus = value; }); }, options: [ @@ -248,11 +288,6 @@ class _ReportingPageState extends State { ], ), ), - ShadButton.ghost( - onPressed: _hasCustomFilters ? _resetFilters : null, - leading: const Icon(LucideIcons.rotateCcw, size: 16), - child: const Text('초기화'), - ), ], ), child: Column( @@ -264,7 +299,7 @@ class _ReportingPageState extends State { child: Row( children: [ Icon( - LucideIcons.circleAlert, + lucide.LucideIcons.circleAlert, size: 16, color: theme.colorScheme.destructive, ), @@ -280,7 +315,7 @@ class _ReportingPageState extends State { const SizedBox(width: 8), ShadButton.ghost( onPressed: _isLoadingWarehouses ? null : _loadWarehouses, - leading: const Icon(LucideIcons.refreshCw, size: 16), + leading: const Icon(lucide.LucideIcons.refreshCw, size: 16), child: const Text('재시도'), ), ], @@ -290,14 +325,12 @@ class _ReportingPageState extends State { Padding( padding: const EdgeInsets.only(bottom: 16), child: Row( - children: [ - const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - const SizedBox(width: 8), - Text('창고 목록을 불러오는 중입니다...', style: theme.textTheme.small), + children: const [ + SuperportSkeleton(width: 180, height: 20), + SizedBox(width: 12), + SuperportSkeleton(width: 140, height: 20), + SizedBox(width: 12), + SuperportSkeleton(width: 120, height: 20), ], ), ), @@ -312,11 +345,13 @@ class _ReportingPageState extends State { children: [ _SummaryRow( label: '기간', - value: _dateRange == null ? '기간을 선택하세요.' : _dateRangeLabel, + value: _appliedDateRange == null + ? '기간을 선택하세요.' + : _dateRangeLabel(_appliedDateRange), ), - _SummaryRow(label: '유형', value: _selectedType.label), - _SummaryRow(label: '창고', value: _selectedWarehouse.label), - _SummaryRow(label: '상태', value: _selectedStatus.label), + _SummaryRow(label: '유형', value: _appliedType.label), + _SummaryRow(label: '창고', value: _appliedWarehouse.label), + _SummaryRow(label: '상태', value: _appliedStatus.label), if (!_canExport) Padding( padding: const EdgeInsets.only(top: 12), @@ -339,9 +374,10 @@ class _ReportingPageState extends State { ), child: SizedBox( height: 240, - child: EmptyState( - icon: LucideIcons.chartBar, - message: '필터를 선택하고 다운로드하면 결과 미리보기가 제공됩니다.', + child: SuperportEmptyState( + icon: lucide.LucideIcons.chartBar, + title: '미리보기 데이터가 없습니다.', + description: '필터를 적용하거나 보고서를 다운로드하면 이 영역에 요약이 표시됩니다.', ), ), ), diff --git a/lib/features/util/postal_search/presentation/pages/postal_search_page.dart b/lib/features/util/postal_search/presentation/pages/postal_search_page.dart index a13e1c7..8820335 100644 --- a/lib/features/util/postal_search/presentation/pages/postal_search_page.dart +++ b/lib/features/util/postal_search/presentation/pages/postal_search_page.dart @@ -1,25 +1,113 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; -import '../../../../../widgets/spec_page.dart'; +import '../../../../../core/constants/app_sections.dart'; +import '../../../../../widgets/app_layout.dart'; +import '../models/postal_search_result.dart'; +import '../widgets/postal_search_dialog.dart'; -class PostalSearchPage extends StatelessWidget { +class PostalSearchPage extends StatefulWidget { const PostalSearchPage({super.key}); + @override + State createState() => _PostalSearchPageState(); +} + +class _PostalSearchPageState extends State { + PostalSearchResult? _lastSelection; + @override Widget build(BuildContext context) { - return const SpecPage( + final theme = ShadTheme.of(context); + + return AppLayout( title: '우편번호 검색', - summary: '모달 기반 우편번호 검색 UI 구성을 정의합니다.', - sections: [ - SpecSection( - title: '모달 구성', - items: [ - '검색어 [Text] 입력 필드', - '결과 리스트: 우편번호 | 시도 | 시군구 | 도로명 | 건물번호', - '선택 시 호출 화면에 우편번호/주소 전달', - ], - ), + subtitle: '창고/고객사 등 주소 입력 폼에서 재사용되는 검색 모달입니다.', + breadcrumbs: const [ + AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), + AppBreadcrumbItem(label: '유틸리티', path: '/utilities/postal-search'), + AppBreadcrumbItem(label: '우편번호 검색'), ], + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: ShadCard( + title: Text('우편번호 검색 모달 미리보기', style: theme.textTheme.h3), + description: Text( + '검색 버튼을 눌러 모달 UI를 확인하세요. 검색 API 연동은 이후 단계에서 진행됩니다.', + style: theme.textTheme.muted, + ), + footer: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ShadButton( + leading: const Icon(LucideIcons.search, size: 16), + onPressed: () async { + final result = await showPostalSearchDialog(context); + if (result != null && mounted) { + setState(() { + _lastSelection = result; + }); + } + }, + child: const Text('모달 열기'), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '우편번호를 검색한 뒤 결과 행을 클릭하면 선택한 주소가 폼에 채워집니다.', + style: theme.textTheme.p, + ), + const SizedBox(height: 16), + if (_lastSelection == null) + Text('선택한 주소가 없습니다.', style: theme.textTheme.muted) + else + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.colorScheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '선택된 우편번호', + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + _lastSelection!.zipcode, + style: theme.textTheme.h4, + ), + const SizedBox(height: 12), + Text( + '주소 구성요소', + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + _lastSelection!.fullAddress.isEmpty + ? '주소 정보가 제공되지 않았습니다.' + : _lastSelection!.fullAddress, + style: theme.textTheme.p, + ), + ], + ), + ), + ], + ), + ), + ), + ), ); } } diff --git a/lib/features/util/postal_search/presentation/widgets/postal_search_dialog.dart b/lib/features/util/postal_search/presentation/widgets/postal_search_dialog.dart index 3f39ea3..560ae3a 100644 --- a/lib/features/util/postal_search/presentation/widgets/postal_search_dialog.dart +++ b/lib/features/util/postal_search/presentation/widgets/postal_search_dialog.dart @@ -255,7 +255,14 @@ class _PostalSearchDialogState extends State<_PostalSearchDialog> { ], ], onRowTap: (index) { - navigator.pop(_results[index]); + if (_results.isEmpty) { + return; + } + final adjustedIndex = (index - 1).clamp( + 0, + _results.length - 1, + ); + navigator.pop(_results[adjustedIndex]); }, emptyLabel: '검색 결과가 없습니다.', ), diff --git a/lib/injection_container.dart b/lib/injection_container.dart index 6a68677..aef6130 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -4,7 +4,9 @@ import 'package:dio/dio.dart'; import 'package:get_it/get_it.dart'; import 'core/network/api_client.dart'; +import 'core/network/api_error.dart'; import 'core/network/interceptors/auth_interceptor.dart'; +import 'core/services/token_storage.dart'; import 'features/masters/customer/data/repositories/customer_repository_remote.dart'; import 'features/masters/customer/domain/repositories/customer_repository.dart'; import 'features/masters/group/data/repositories/group_repository_remote.dart'; @@ -55,13 +57,20 @@ Future initInjection({ final dio = Dio(options); // 인터셉터 등록 (Auth 등) - dio.interceptors.add(AuthInterceptor()); + final tokenStorage = createTokenStorage(); + sl.registerLazySingleton(() => tokenStorage); + sl.registerLazySingleton(ApiErrorMapper.new); + + final authInterceptor = AuthInterceptor(tokenStorage: tokenStorage, dio: dio); + dio.interceptors.add(authInterceptor); // 개발용 로거는 필요 시 추가 (pretty_dio_logger 등) // if (!kReleaseMode) { dio.interceptors.add(PrettyDioLogger(...)); } // ApiClient 등록 - sl.registerLazySingleton(() => ApiClient(dio: dio)); + sl.registerLazySingleton( + () => ApiClient(dio: dio, errorMapper: sl()), + ); // 리포지토리 등록 (예: 벤더) sl.registerLazySingleton( diff --git a/lib/main.dart b/lib/main.dart index b9c82c6..99d8307 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,7 +4,10 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import 'core/config/environment.dart'; import 'core/routing/app_router.dart'; +import 'core/theme/superport_shad_theme.dart'; +import 'core/theme/theme_controller.dart'; import 'injection_container.dart'; +import 'core/permissions/permission_manager.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -13,28 +16,56 @@ Future main() async { runApp(const SuperportApp()); } -class SuperportApp extends StatelessWidget { +class SuperportApp extends StatefulWidget { const SuperportApp({super.key}); + @override + State createState() => _SuperportAppState(); +} + +class _SuperportAppState extends State { + late final ThemeController _themeController; + late final PermissionManager _permissionManager; + + @override + void initState() { + super.initState(); + _themeController = ThemeController(); + _permissionManager = PermissionManager(); + } + + @override + void dispose() { + _themeController.dispose(); + _permissionManager.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return ShadApp.router( - title: 'Superport v2', - routerConfig: appRouter, - debugShowCheckedModeBanner: false, - supportedLocales: const [Locale('ko', 'KR'), Locale('en', 'US')], - localizationsDelegates: const [ - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - theme: ShadThemeData( - colorScheme: const ShadSlateColorScheme.light(), - brightness: Brightness.light, - ), - darkTheme: ShadThemeData( - colorScheme: const ShadSlateColorScheme.dark(), - brightness: Brightness.dark, + return PermissionScope( + manager: _permissionManager, + child: ThemeControllerScope( + controller: _themeController, + child: AnimatedBuilder( + animation: _themeController, + builder: (context, _) { + return ShadApp.router( + title: 'Superport v2', + routerConfig: appRouter, + debugShowCheckedModeBanner: false, + supportedLocales: const [Locale('ko', 'KR'), Locale('en', 'US')], + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + theme: SuperportShadTheme.light(), + darkTheme: SuperportShadTheme.dark(), + themeMode: _themeController.mode, + ); + }, + ), ), ); } diff --git a/lib/widgets/app_layout.dart b/lib/widgets/app_layout.dart index 5e5e59c..b1c56e0 100644 --- a/lib/widgets/app_layout.dart +++ b/lib/widgets/app_layout.dart @@ -35,15 +35,8 @@ class AppLayout extends StatelessWidget { _BreadcrumbBar(items: breadcrumbs), const SizedBox(height: 16), ], - PageHeader( - title: title, - subtitle: subtitle, - actions: actions, - ), - if (toolbar != null) ...[ - const SizedBox(height: 16), - toolbar!, - ], + PageHeader(title: title, subtitle: subtitle, actions: actions), + if (toolbar != null) ...[const SizedBox(height: 16), toolbar!], const SizedBox(height: 24), child, ], @@ -54,11 +47,7 @@ class AppLayout extends StatelessWidget { } class AppBreadcrumbItem { - const AppBreadcrumbItem({ - required this.label, - this.path, - this.onTap, - }); + const AppBreadcrumbItem({required this.label, this.path, this.onTap}); final String label; final String? path; @@ -94,7 +83,10 @@ class _BreadcrumbBar extends StatelessWidget { size: 14, color: colorScheme.mutedForeground, ), - _BreadcrumbChip(item: items[index], isLast: index == items.length - 1), + _BreadcrumbChip( + item: items[index], + isLast: index == items.length - 1, + ), ], ], ); @@ -113,7 +105,9 @@ class _BreadcrumbChip extends StatelessWidget { final label = Text( item.label, style: theme.textTheme.small.copyWith( - color: isLast ? theme.colorScheme.foreground : theme.colorScheme.mutedForeground, + color: isLast + ? theme.colorScheme.foreground + : theme.colorScheme.mutedForeground, ), ); diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index b8d45ee..08bd61e 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import '../core/constants/app_sections.dart'; +import '../core/theme/theme_controller.dart'; +import '../core/permissions/permission_manager.dart'; class AppShell extends StatelessWidget { const AppShell({ @@ -19,6 +21,13 @@ class AppShell extends StatelessWidget { return LayoutBuilder( builder: (context, constraints) { final isWide = constraints.maxWidth >= 960; + final manager = PermissionScope.of(context); + final filteredPages = [ + for (final section in appSections) + for (final page in section.pages) + if (manager.can(page.path, PermissionAction.view)) page, + ]; + final pages = filteredPages.isEmpty ? allAppPages : filteredPages; if (isWide) { return Scaffold( appBar: AppBar( @@ -26,14 +35,14 @@ class AppShell extends StatelessWidget { actions: [ IconButton( tooltip: '로그아웃', - icon: const Icon(LucideIcons.logOut), + icon: const Icon(lucide.LucideIcons.logOut), onPressed: () => context.go(loginRoutePath), ), ], ), body: Row( children: [ - _NavigationRail(currentLocation: currentLocation), + _NavigationRail(currentLocation: currentLocation, pages: pages), const VerticalDivider(width: 1), Expanded(child: child), ], @@ -47,7 +56,7 @@ class AppShell extends StatelessWidget { actions: [ IconButton( tooltip: '로그아웃', - icon: const Icon(LucideIcons.logOut), + icon: const Icon(lucide.LucideIcons.logOut), onPressed: () => context.go(loginRoutePath), ), ], @@ -60,6 +69,7 @@ class AppShell extends StatelessWidget { Navigator.of(context).pop(); context.go(path); }, + pages: pages, ), ), ), @@ -71,16 +81,18 @@ class AppShell extends StatelessWidget { } class _NavigationRail extends StatelessWidget { - const _NavigationRail({required this.currentLocation}); + const _NavigationRail({required this.currentLocation, required this.pages}); final String currentLocation; + final List pages; @override Widget build(BuildContext context) { - final pages = allAppPages; final selectedIndex = _selectedIndex(currentLocation, pages); final theme = Theme.of(context); final colorScheme = theme.colorScheme; + final themeController = ThemeControllerScope.of(context); + final currentThemeMode = themeController.mode; return Container( width: 104, @@ -151,6 +163,13 @@ class _NavigationRail extends StatelessWidget { }, ), ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 16), + child: _ThemeMenuButton( + mode: currentThemeMode, + onChanged: themeController.update, + ), + ), ], ), ); @@ -158,19 +177,37 @@ class _NavigationRail extends StatelessWidget { } class _NavigationList extends StatelessWidget { - const _NavigationList({required this.currentLocation, required this.onTap}); + const _NavigationList({ + required this.currentLocation, + required this.onTap, + required this.pages, + }); final String currentLocation; final ValueChanged onTap; + final List pages; @override Widget build(BuildContext context) { - final pages = allAppPages; final selectedIndex = _selectedIndex(currentLocation, pages); + final themeController = ThemeControllerScope.of(context); return ListView.builder( - itemCount: pages.length, + itemCount: pages.length + 1, itemBuilder: (context, index) { + if (index == pages.length) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: _ThemeMenuButton( + mode: themeController.mode, + onChanged: (mode) { + themeController.update(mode); + Navigator.of(context).maybePop(); + }, + ), + ); + } + final page = pages[index]; final selected = index == selectedIndex; return ListTile( @@ -190,6 +227,77 @@ class _NavigationList extends StatelessWidget { } } +class _ThemeMenuButton extends StatelessWidget { + const _ThemeMenuButton({required this.mode, required this.onChanged}); + + final ThemeMode mode; + final ValueChanged 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( + tooltip: '테마 변경', + onSelected: onChanged, + itemBuilder: (context) => ThemeMode.values + .map( + (value) => PopupMenuItem( + 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 pages) { final normalized = location.toLowerCase(); final exact = pages.indexWhere( diff --git a/lib/widgets/components/empty_state.dart b/lib/widgets/components/empty_state.dart index 9c2404d..88dfb57 100644 --- a/lib/widgets/components/empty_state.dart +++ b/lib/widgets/components/empty_state.dart @@ -1,11 +1,21 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; -class EmptyState extends StatelessWidget { - const EmptyState({super.key, required this.message, this.icon}); +/// 데이터가 없을 때 사용자에게 명확한 안내를 제공하는 공통 위젯. +class SuperportEmptyState extends StatelessWidget { + const SuperportEmptyState({ + super.key, + required this.title, + this.description, + this.icon = lucide.LucideIcons.inbox, + this.action, + }); - final String message; + final String title; + final String? description; final IconData? icon; + final Widget? action; @override Widget build(BuildContext context) { @@ -16,8 +26,17 @@ class EmptyState extends StatelessWidget { children: [ if (icon != null) Icon(icon, size: 48, color: theme.colorScheme.mutedForeground), - if (icon != null) const SizedBox(height: 16), - Text(message, style: theme.textTheme.muted), + const SizedBox(height: 16), + Text(title, style: theme.textTheme.h4), + if (description != null) ...[ + const SizedBox(height: 8), + Text( + description!, + style: theme.textTheme.muted, + textAlign: TextAlign.center, + ), + ], + if (action != null) ...[const SizedBox(height: 20), action!], ], ), ); diff --git a/lib/widgets/components/feedback.dart b/lib/widgets/components/feedback.dart new file mode 100644 index 0000000..5b71a06 --- /dev/null +++ b/lib/widgets/components/feedback.dart @@ -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), + ], + ], + ), + ); + } +} diff --git a/lib/widgets/components/filter_bar.dart b/lib/widgets/components/filter_bar.dart index a93c653..7487a2e 100644 --- a/lib/widgets/components/filter_bar.dart +++ b/lib/widgets/components/filter_bar.dart @@ -3,23 +3,162 @@ import 'package:shadcn_ui/shadcn_ui.dart'; /// 검색/필터 영역을 위한 공통 래퍼. class FilterBar extends StatelessWidget { - const FilterBar({super.key, required this.children}); + const FilterBar({ + super.key, + required this.children, + this.title = '검색 및 필터', + this.actions, + this.spacing = 16, + this.runSpacing = 16, + this.actionConfig, + this.leading, + }); final List children; + final String? title; + final List? actions; + final double spacing; + final double runSpacing; + final FilterBarActionConfig? actionConfig; + final Widget? leading; @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); + final computedActions = _resolveActions(context); + final hasHeading = + (title != null && title!.isNotEmpty) || computedActions.isNotEmpty; + return ShadCard( - title: Text('검색 및 필터', style: theme.textTheme.h3), - child: Align( - alignment: Alignment.centerLeft, - child: Wrap( - spacing: 16, - runSpacing: 16, - children: children, - ), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (hasHeading) + Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (leading != null) ...[ + leading!, + const SizedBox(width: 12), + ], + if (title != null && title!.isNotEmpty) + Text(title!, style: theme.textTheme.h3), + ], + ), + ), + if (computedActions.isNotEmpty) + Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.end, + children: computedActions, + ), + ], + ), + ), + Wrap(spacing: spacing, runSpacing: runSpacing, children: children), + ], ), ); } + + List _resolveActions(BuildContext context) { + final items = []; + 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; } diff --git a/lib/widgets/components/form_field.dart b/lib/widgets/components/form_field.dart new file mode 100644 index 0000000..97bf976 --- /dev/null +++ b/lib/widgets/components/form_field.dart @@ -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? onChanged; + final ValueChanged? 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 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, + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/components/page_header.dart b/lib/widgets/components/page_header.dart index d7b220c..d2283fd 100644 --- a/lib/widgets/components/page_header.dart +++ b/lib/widgets/components/page_header.dart @@ -25,10 +25,7 @@ class PageHeader extends StatelessWidget { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (leading != null) ...[ - leading!, - const SizedBox(width: 16), - ], + if (leading != null) ...[leading!, const SizedBox(width: 16)], Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -42,16 +39,9 @@ class PageHeader extends StatelessWidget { ), ), if (actions != null && actions!.isNotEmpty) ...[ - Wrap( - spacing: 12, - runSpacing: 12, - children: actions!, - ), - ], - if (trailing != null) ...[ - const SizedBox(width: 16), - trailing!, + Wrap(spacing: 12, runSpacing: 12, children: actions!), ], + if (trailing != null) ...[const SizedBox(width: 16), trailing!], ], ); } diff --git a/lib/widgets/components/responsive.dart b/lib/widgets/components/responsive.dart index 4b4523c..1885dcc 100644 --- a/lib/widgets/components/responsive.dart +++ b/lib/widgets/components/responsive.dart @@ -1,6 +1,98 @@ +import 'package:flutter/widgets.dart'; + const double desktopBreakpoint = 1200; const double tabletBreakpoint = 960; +enum DeviceBreakpoint { mobile, tablet, desktop } + +DeviceBreakpoint breakpointForWidth(double width) { + if (width >= desktopBreakpoint) { + return DeviceBreakpoint.desktop; + } + if (width >= tabletBreakpoint) { + return DeviceBreakpoint.tablet; + } + return DeviceBreakpoint.mobile; +} + bool isDesktop(double width) => width >= desktopBreakpoint; -bool isTablet(double width) => width >= tabletBreakpoint && width < desktopBreakpoint; +bool isTablet(double width) => + width >= tabletBreakpoint && width < desktopBreakpoint; bool isMobile(double width) => width < tabletBreakpoint; + +bool isDesktopContext(BuildContext context) => + isDesktop(MediaQuery.of(context).size.width); +bool isTabletContext(BuildContext context) => + isTablet(MediaQuery.of(context).size.width); +bool isMobileContext(BuildContext context) => + isMobile(MediaQuery.of(context).size.width); + +class ResponsiveBreakpoints { + ResponsiveBreakpoints._(this.width) : breakpoint = breakpointForWidth(width); + + final double width; + final DeviceBreakpoint breakpoint; + + bool get isMobile => breakpoint == DeviceBreakpoint.mobile; + bool get isTablet => breakpoint == DeviceBreakpoint.tablet; + bool get isDesktop => breakpoint == DeviceBreakpoint.desktop; + + static ResponsiveBreakpoints of(BuildContext context) { + final size = MediaQuery.of(context).size; + return ResponsiveBreakpoints._(size.width); + } +} + +class ResponsiveLayoutBuilder extends StatelessWidget { + const ResponsiveLayoutBuilder({ + super.key, + required this.mobile, + this.tablet, + required this.desktop, + }); + + final WidgetBuilder mobile; + final WidgetBuilder? tablet; + final WidgetBuilder desktop; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final breakpoint = breakpointForWidth(constraints.maxWidth); + switch (breakpoint) { + case DeviceBreakpoint.mobile: + return mobile(context); + case DeviceBreakpoint.tablet: + final tabletBuilder = tablet ?? desktop; + return tabletBuilder(context); + case DeviceBreakpoint.desktop: + return desktop(context); + } + }, + ); + } +} + +class ResponsiveVisibility extends StatelessWidget { + const ResponsiveVisibility({ + super.key, + required this.child, + this.replacement = const SizedBox.shrink(), + this.visibleOn = const { + DeviceBreakpoint.mobile, + DeviceBreakpoint.tablet, + DeviceBreakpoint.desktop, + }, + }); + + final Widget child; + final Widget replacement; + final Set visibleOn; + + @override + Widget build(BuildContext context) { + final breakpoint = ResponsiveBreakpoints.of(context).breakpoint; + return visibleOn.contains(breakpoint) ? child : replacement; + } +} diff --git a/lib/widgets/components/superport_date_picker.dart b/lib/widgets/components/superport_date_picker.dart new file mode 100644 index 0000000..edb2393 --- /dev/null +++ b/lib/widgets/components/superport_date_picker.dart @@ -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 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 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; + } +} diff --git a/lib/widgets/components/superport_dialog.dart b/lib/widgets/components/superport_dialog.dart index 617dd2e..d3eaadb 100644 --- a/lib/widgets/components/superport_dialog.dart +++ b/lib/widgets/components/superport_dialog.dart @@ -1,40 +1,327 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; -/// 공통 모달 다이얼로그. +import 'keyboard_shortcuts.dart'; + +const double _kDialogMaxWidth = 640; +const double _kDialogMobileBreakpoint = 640; +const EdgeInsets _kDialogDesktopInset = EdgeInsets.symmetric( + horizontal: 24, + vertical: 32, +); +const EdgeInsets _kDialogBodyPadding = EdgeInsets.symmetric( + horizontal: 20, + vertical: 24, +); + +/// 공통 모달 다이얼로그 scaffold. +/// +/// - ShadCard 기반으로 헤더/본문/푸터 영역을 분리한다. +/// - 모바일에서는 전체 화면으로 확장되며 시스템 인셋을 자동 반영한다. +/// - 닫기 버튼 및 사용자 정의 액션을 지원한다. +class SuperportDialog extends StatelessWidget { + const SuperportDialog({ + super.key, + required this.title, + this.description, + this.child = const SizedBox.shrink(), + this.primaryAction, + this.secondaryAction, + this.mobileFullscreen = true, + this.constraints, + this.actions, + this.contentPadding, + this.header, + this.footer, + this.showCloseButton = true, + this.onClose, + this.scrollable = true, + this.insetPadding, + this.onSubmit, + this.enableFocusTrap = true, + }); + + final String title; + final String? description; + final Widget child; + final Widget? primaryAction; + final Widget? secondaryAction; + final bool mobileFullscreen; + final BoxConstraints? constraints; + final List? actions; + final EdgeInsetsGeometry? contentPadding; + final Widget? header; + final Widget? footer; + final bool showCloseButton; + final VoidCallback? onClose; + final bool scrollable; + final EdgeInsets? insetPadding; + final FutureOr Function()? onSubmit; + final bool enableFocusTrap; + + static Future show({ + required BuildContext context, + required SuperportDialog dialog, + bool barrierDismissible = true, + }) { + return showDialog( + 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().toList(); + if (filtered.isEmpty) { + return null; + } + return _DialogFooter(children: filtered); + } + + final fallback = [ + if (secondaryAction != null) secondaryAction!, + primaryAction ?? + ShadButton.ghost( + onPressed: onClose ?? () => Navigator.of(context).maybePop(), + child: const Text('닫기'), + ), + ].whereType().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 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 showSuperportDialog({ required BuildContext context, required String title, String? description, required Widget body, + Widget? primaryAction, + Widget? secondaryAction, List? actions, + bool mobileFullscreen = true, bool barrierDismissible = true, + BoxConstraints? constraints, + EdgeInsetsGeometry? contentPadding, + bool scrollable = true, + bool showCloseButton = true, + VoidCallback? onClose, + FutureOr Function()? onSubmit, + bool enableFocusTrap = true, }) { - return showDialog( + return SuperportDialog.show( context: context, barrierDismissible: barrierDismissible, - builder: (dialogContext) { - final theme = ShadTheme.of(dialogContext); - return Dialog( - insetPadding: const EdgeInsets.all(24), - clipBehavior: Clip.antiAlias, - child: ShadCard( - title: Text(title, style: theme.textTheme.h3), - description: description == null - ? null - : Text(description, style: theme.textTheme.muted), - footer: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: actions ?? [ - ShadButton.ghost( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('닫기'), - ), - ], - ), - child: body, - ), - ); - }, + dialog: SuperportDialog( + title: title, + description: description, + primaryAction: primaryAction, + secondaryAction: secondaryAction, + actions: actions, + constraints: constraints, + mobileFullscreen: mobileFullscreen, + contentPadding: contentPadding, + scrollable: scrollable, + showCloseButton: showCloseButton, + onClose: onClose, + onSubmit: onSubmit, + enableFocusTrap: enableFocusTrap, + child: body, + ), ); } diff --git a/lib/widgets/components/superport_table.dart b/lib/widgets/components/superport_table.dart index bb15e64..ee10236 100644 --- a/lib/widgets/components/superport_table.dart +++ b/lib/widgets/components/superport_table.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -5,54 +7,107 @@ import 'package:shadcn_ui/shadcn_ui.dart'; class SuperportTable extends StatelessWidget { const SuperportTable({ super.key, - required this.columns, - required this.rows, + required List columns, + required List> rows, this.columnSpanExtent, this.rowHeight = 56, + this.maxHeight, this.onRowTap, this.emptyLabel = '데이터가 없습니다.', - }); + }) : _columns = columns, + _rows = rows, + _headerCells = null, + _rowCells = null; - final List columns; - final List> rows; + const SuperportTable.fromCells({ + super.key, + required List header, + required List> rows, + this.columnSpanExtent, + this.rowHeight = 56, + this.maxHeight, + this.onRowTap, + this.emptyLabel = '데이터가 없습니다.', + }) : _columns = null, + _rows = null, + _headerCells = header, + _rowCells = rows; + + final List? _columns; + final List>? _rows; + final List? _headerCells; + final List>? _rowCells; final TableSpanExtent? Function(int index)? columnSpanExtent; final double rowHeight; + final double? maxHeight; final void Function(int index)? onRowTap; final String emptyLabel; @override Widget build(BuildContext context) { - if (rows.isEmpty) { - final theme = ShadTheme.of(context); - return Padding( - padding: const EdgeInsets.all(32), - child: Center( - child: Text(emptyLabel, style: theme.textTheme.muted), - ), - ); - } + late final List headerCells; + late final List> tableRows; - final tableRows = [ - for (final row in rows) - row - .map( - (cell) => cell is ShadTableCell ? cell : ShadTableCell(child: cell), - ) - .toList(), - ]; - - return ShadTable.list( - header: columns + if (_rowCells case final rows?) { + if (rows.isEmpty) { + final theme = ShadTheme.of(context); + return Padding( + padding: const EdgeInsets.all(32), + child: Center(child: Text(emptyLabel, style: theme.textTheme.muted)), + ); + } + final header = _headerCells; + if (header == null) { + throw StateError('header cells must not be null when using fromCells'); + } + headerCells = header; + tableRows = rows; + } else { + final rows = _rows; + if (rows == null || rows.isEmpty) { + final theme = ShadTheme.of(context); + return Padding( + padding: const EdgeInsets.all(32), + child: Center(child: Text(emptyLabel, style: theme.textTheme.muted)), + ); + } + headerCells = _columns! .map( (cell) => cell is ShadTableCell ? cell : ShadTableCell.header(child: cell), ) - .toList(), - columnSpanExtent: columnSpanExtent, - rowSpanExtent: (_) => FixedTableSpanExtent(rowHeight), - onRowTap: onRowTap, - children: tableRows, + .toList(); + tableRows = [ + for (final row in rows) + row + .map( + (cell) => + cell is ShadTableCell ? cell : ShadTableCell(child: cell), + ) + .toList(), + ]; + } + + final estimatedHeight = (tableRows.length + 1) * rowHeight; + final minHeight = rowHeight * 2; + final effectiveHeight = math.max( + minHeight, + maxHeight == null + ? estimatedHeight + : math.min(estimatedHeight, maxHeight!), + ); + + return SizedBox( + height: effectiveHeight, + child: ShadTable.list( + header: headerCells, + columnSpanExtent: columnSpanExtent, + rowSpanExtent: (_) => FixedTableSpanExtent(rowHeight), + onRowTap: onRowTap, + primary: false, + children: tableRows, + ), ); } } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..d0e7f79 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include 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); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..b29e9ba 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index e777c67..15a1671 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,8 +5,10 @@ import FlutterMacOS import Foundation +import flutter_secure_storage_macos import path_provider_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 99de1b1..b588e03 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -155,6 +155,54 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_shaders: dependency: transitive description: @@ -233,10 +281,10 @@ packages: dependency: transitive description: name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.6.7" leak_tracker: dependency: transitive description: @@ -554,6 +602,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" + url: "https://pub.dev" + source: hosted + version: "5.14.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c9dd0e7..fbc4a14 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: dio: ^5.5.0+1 get_it: ^7.7.0 flutter_dotenv: ^5.1.0 + flutter_secure_storage: ^9.2.2 dev_dependencies: flutter_test: diff --git a/test/core/permissions/permission_manager_test.dart b/test/core/permissions/permission_manager_test.dart new file mode 100644 index 0000000..b6029ce --- /dev/null +++ b/test/core/permissions/permission_manager_test.dart @@ -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); + }); +} diff --git a/test/features/approvals/presentation/pages/approval_page_test.dart b/test/features/approvals/presentation/pages/approval_page_test.dart index 965b866..3915fbd 100644 --- a/test/features/approvals/presentation/pages/approval_page_test.dart +++ b/test/features/approvals/presentation/pages/approval_page_test.dart @@ -55,6 +55,5 @@ void main() { repository = _MockApprovalRepository(); GetIt.I.registerLazySingleton(() => repository); }); - }); } diff --git a/test/features/masters/customer/presentation/pages/customer_page_test.dart b/test/features/masters/customer/presentation/pages/customer_page_test.dart index 2075459..2a148d2 100644 --- a/test/features/masters/customer/presentation/pages/customer_page_test.dart +++ b/test/features/masters/customer/presentation/pages/customer_page_test.dart @@ -138,6 +138,48 @@ void main() { expect(find.text('고객사명을 입력하세요.'), findsOneWidget); }); + testWidgets('우편번호를 수동 입력하면 검색 안내를 노출한다', (tester) async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isPartner: any(named: 'isPartner'), + isGeneral: any(named: 'isGeneral'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: const [], + page: 1, + pageSize: 20, + total: 0, + ), + ); + + await tester.pumpWidget( + _buildApp(CustomerPage(routeUri: Uri(path: '/masters/customers'))), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('신규 등록')); + await tester.pumpAndSettle(); + + final fields = find.descendant( + of: find.byType(Dialog), + matching: find.byType(EditableText), + ); + + await tester.enterText(fields.at(0), 'C-200'); + await tester.enterText(fields.at(1), '검색 필요 고객'); + await tester.enterText(fields.at(4), '06000'); + + await tester.tap(find.text('등록')); + await tester.pump(); + + expect(find.text('우편번호 검색으로 주소를 선택하세요.'), findsOneWidget); + }); + testWidgets('신규 등록 성공 시 repository.create 호출', (tester) async { var listCallCount = 0; when( diff --git a/test/features/masters/group_permission/presentation/pages/group_permission_page_test.dart b/test/features/masters/group_permission/presentation/pages/group_permission_page_test.dart index 36fc5b2..d5a4a4a 100644 --- a/test/features/masters/group_permission/presentation/pages/group_permission_page_test.dart +++ b/test/features/masters/group_permission/presentation/pages/group_permission_page_test.dart @@ -139,7 +139,7 @@ void main() { await tester.pumpWidget(_buildApp(const GroupPermissionPage())); await tester.pumpAndSettle(); - expect(find.text('대시보드'), findsOneWidget); + expect(find.text('대시보드'), findsWidgets); expect(find.text('관리자'), findsOneWidget); }); @@ -278,7 +278,7 @@ void main() { expect(capturedInput?.canCreate, isTrue); expect(capturedInput?.canUpdate, isTrue); expect(find.byType(Dialog), findsNothing); - expect(find.text('대시보드'), findsOneWidget); + expect(find.text('대시보드'), findsWidgets); verify(() => permissionRepository.create(any())).called(1); }); }); diff --git a/test/features/masters/product/presentation/pages/product_page_test.dart b/test/features/masters/product/presentation/pages/product_page_test.dart index 6e61519..ec1f5ce 100644 --- a/test/features/masters/product/presentation/pages/product_page_test.dart +++ b/test/features/masters/product/presentation/pages/product_page_test.dart @@ -49,7 +49,9 @@ void main() { testWidgets('플래그 Off 시 스펙 문서 화면을 노출한다', (tester) async { dotenv.testLoad(fileInput: 'FEATURE_PRODUCTS_ENABLED=false\n'); - await tester.pumpWidget(_buildApp(const ProductPage())); + await tester.pumpWidget( + _buildApp(ProductPage(routeUri: Uri(path: '/masters/products'))), + ); await tester.pump(); expect(find.text('장비 모델(제품) 관리'), findsOneWidget); @@ -134,7 +136,9 @@ void main() { ), ); - await tester.pumpWidget(_buildApp(const ProductPage())); + await tester.pumpWidget( + _buildApp(ProductPage(routeUri: Uri(path: '/masters/products'))), + ); await tester.pumpAndSettle(); expect(find.text('P-001'), findsOneWidget); @@ -169,7 +173,9 @@ void main() { ), ); - await tester.pumpWidget(_buildApp(const ProductPage())); + await tester.pumpWidget( + _buildApp(ProductPage(routeUri: Uri(path: '/masters/products'))), + ); await tester.pumpAndSettle(); await tester.tap(find.text('신규 등록')); @@ -243,7 +249,9 @@ void main() { ); }); - await tester.pumpWidget(_buildApp(const ProductPage())); + await tester.pumpWidget( + _buildApp(ProductPage(routeUri: Uri(path: '/masters/products'))), + ); await tester.pumpAndSettle(); await tester.tap(find.text('신규 등록')); diff --git a/test/features/masters/user/presentation/pages/user_page_test.dart b/test/features/masters/user/presentation/pages/user_page_test.dart index 7a89280..5d39fcd 100644 --- a/test/features/masters/user/presentation/pages/user_page_test.dart +++ b/test/features/masters/user/presentation/pages/user_page_test.dart @@ -55,7 +55,6 @@ void main() { group('플래그 On', () { late _MockUserRepository userRepository; late _MockGroupRepository groupRepository; - setUp(() { dotenv.testLoad(fileInput: 'FEATURE_USERS_ENABLED=true\n'); userRepository = _MockUserRepository(); @@ -153,6 +152,14 @@ void main() { }); testWidgets('신규 등록 성공', (tester) async { + final view = tester.view; + view.physicalSize = const Size(1280, 800); + view.devicePixelRatio = 1.0; + addTearDown(() { + view.resetPhysicalSize(); + view.resetDevicePixelRatio(); + }); + var listCallCount = 0; when( () => userRepository.list( @@ -214,9 +221,21 @@ void main() { await tester.enterText(editableTexts.at(0), 'A010'); await tester.enterText(editableTexts.at(1), '신규 사용자'); - await tester.tap(find.text('그룹을 선택하세요')); - await tester.pumpAndSettle(); - await tester.tap(find.text('관리자')); + final selectFinder = find.descendant( + of: dialog, + matching: find.byType(ShadSelect), + ); + final selectElement = tester.element(selectFinder); + final renderBox = selectElement.renderObject as RenderBox; + final globalCenter = renderBox.localToGlobal( + renderBox.size.center(Offset.zero), + ); + await tester.tapAt(globalCenter); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + final adminOption = find.text('관리자', skipOffstage: false); + expect(adminOption, findsWidgets); + await tester.tap(adminOption.first, warnIfMissed: false); await tester.pumpAndSettle(); await tester.tap(find.text('등록')); diff --git a/test/features/masters/vendor/presentation/pages/vendor_page_test.dart b/test/features/masters/vendor/presentation/pages/vendor_page_test.dart index 19d05e4..af5d647 100644 --- a/test/features/masters/vendor/presentation/pages/vendor_page_test.dart +++ b/test/features/masters/vendor/presentation/pages/vendor_page_test.dart @@ -41,7 +41,9 @@ void main() { testWidgets('FEATURE_VENDORS_ENABLED=false 이면 스펙 페이지를 노출한다', (tester) async { dotenv.testLoad(fileInput: 'FEATURE_VENDORS_ENABLED=false\n'); - await tester.pumpWidget(_buildApp(const VendorPage())); + await tester.pumpWidget( + _buildApp(VendorPage(routeUri: Uri(path: '/masters/vendors'))), + ); await tester.pump(); expect(find.text('제조사(벤더) 관리'), findsOneWidget); @@ -71,7 +73,9 @@ void main() { ), ); - await tester.pumpWidget(_buildApp(const VendorPage())); + await tester.pumpWidget( + _buildApp(VendorPage(routeUri: Uri(path: '/masters/vendors'))), + ); await tester.pumpAndSettle(); expect(find.text('V-001'), findsOneWidget); @@ -101,7 +105,9 @@ void main() { ), ); - await tester.pumpWidget(_buildApp(const VendorPage())); + await tester.pumpWidget( + _buildApp(VendorPage(routeUri: Uri(path: '/masters/vendors'))), + ); await tester.pumpAndSettle(); await tester.tap(find.text('신규 등록')); @@ -155,7 +161,9 @@ void main() { ); }); - await tester.pumpWidget(_buildApp(const VendorPage())); + await tester.pumpWidget( + _buildApp(VendorPage(routeUri: Uri(path: '/masters/vendors'))), + ); await tester.pumpAndSettle(); await tester.tap(find.text('신규 등록')); diff --git a/test/features/masters/warehouse/presentation/pages/warehouse_page_test.dart b/test/features/masters/warehouse/presentation/pages/warehouse_page_test.dart index cf30fea..281f31a 100644 --- a/test/features/masters/warehouse/presentation/pages/warehouse_page_test.dart +++ b/test/features/masters/warehouse/presentation/pages/warehouse_page_test.dart @@ -9,11 +9,16 @@ import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart'; import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart'; import 'package:superport_v2/features/masters/warehouse/presentation/pages/warehouse_page.dart'; +import 'package:superport_v2/features/util/postal_search/domain/entities/postal_code.dart'; +import 'package:superport_v2/features/util/postal_search/domain/repositories/postal_search_repository.dart'; class _MockWarehouseRepository extends Mock implements WarehouseRepository {} class _FakeWarehouseInput extends Fake implements WarehouseInput {} +class _MockPostalSearchRepository extends Mock + implements PostalSearchRepository {} + Widget _buildApp(Widget child) { return MaterialApp( home: ShadTheme( @@ -41,7 +46,9 @@ void main() { testWidgets('플래그 Off 시 스펙 화면', (tester) async { dotenv.testLoad(fileInput: 'FEATURE_WAREHOUSES_ENABLED=false\n'); - await tester.pumpWidget(_buildApp(const WarehousePage())); + await tester.pumpWidget( + _buildApp(WarehousePage(routeUri: Uri(path: '/masters/warehouses'))), + ); await tester.pump(); expect(find.text('입고지(창고) 관리'), findsOneWidget); @@ -50,11 +57,32 @@ void main() { group('플래그 On', () { late _MockWarehouseRepository repository; + late _MockPostalSearchRepository postalRepository; setUp(() { dotenv.testLoad(fileInput: 'FEATURE_WAREHOUSES_ENABLED=true\n'); repository = _MockWarehouseRepository(); + postalRepository = _MockPostalSearchRepository(); GetIt.I.registerLazySingleton(() => repository); + GetIt.I.registerLazySingleton( + () => postalRepository, + ); + when( + () => postalRepository.search( + keyword: any(named: 'keyword'), + limit: any(named: 'limit'), + ), + ).thenAnswer( + (_) async => [ + PostalCode( + zipcode: '06000', + sido: '서울특별시', + sigungu: '강남구', + roadName: '테헤란로', + buildingMainNo: 100, + ), + ], + ); }); testWidgets('목록 조회 후 테이블 표시', (tester) async { @@ -81,7 +109,9 @@ void main() { ), ); - await tester.pumpWidget(_buildApp(const WarehousePage())); + await tester.pumpWidget( + _buildApp(WarehousePage(routeUri: Uri(path: '/masters/warehouses'))), + ); await tester.pumpAndSettle(); expect(find.text('WH-001'), findsOneWidget); @@ -108,7 +138,9 @@ void main() { ), ); - await tester.pumpWidget(_buildApp(const WarehousePage())); + await tester.pumpWidget( + _buildApp(WarehousePage(routeUri: Uri(path: '/masters/warehouses'))), + ); await tester.pumpAndSettle(); await tester.tap(find.text('신규 등록')); @@ -164,7 +196,9 @@ void main() { ); }); - await tester.pumpWidget(_buildApp(const WarehousePage())); + await tester.pumpWidget( + _buildApp(WarehousePage(routeUri: Uri(path: '/masters/warehouses'))), + ); await tester.pumpAndSettle(); await tester.tap(find.text('신규 등록')); @@ -177,14 +211,31 @@ void main() { await tester.enterText(fields.at(0), 'WH-100'); await tester.enterText(fields.at(1), '신규 창고'); - await tester.enterText(fields.at(2), '12345'); - await tester.enterText(fields.at(3), '주소'); + await tester.enterText(fields.at(2), '06000'); + await tester.tap( + find.descendant( + of: find.byType(Dialog), + matching: find.widgetWithText(ShadButton, '검색'), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('06000').last); + await tester.pumpAndSettle(); + + final updatedFields = find.descendant( + of: find.byType(Dialog), + matching: find.byType(EditableText), + ); + + await tester.enterText(updatedFields.at(3), '주소'); await tester.tap(find.text('등록')); await tester.pumpAndSettle(); expect(capturedInput, isNotNull); expect(capturedInput?.warehouseCode, 'WH-100'); + expect(capturedInput?.zipcode, '06000'); expect(find.byType(Dialog), findsNothing); expect(find.text('WH-100'), findsOneWidget); verify(() => repository.create(any())).called(1); diff --git a/test/features/util/postal_search/presentation/widgets/postal_search_dialog_test.dart b/test/features/util/postal_search/presentation/widgets/postal_search_dialog_test.dart new file mode 100644 index 0000000..ad0b543 --- /dev/null +++ b/test/features/util/postal_search/presentation/widgets/postal_search_dialog_test.dart @@ -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(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(); + }); +} diff --git a/test/helpers/test_app.dart b/test/helpers/test_app.dart new file mode 100644 index 0000000..3e54ae9 --- /dev/null +++ b/test/helpers/test_app.dart @@ -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), + ), + ); +} diff --git a/test/navigation/navigation_flow_test.dart b/test/navigation/navigation_flow_test.dart new file mode 100644 index 0000000..fac919d --- /dev/null +++ b/test/navigation/navigation_flow_test.dart @@ -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); + }); +} diff --git a/test/widgets/dialog_keyboard_shortcuts_test.dart b/test/widgets/dialog_keyboard_shortcuts_test.dart new file mode 100644 index 0000000..681dd08 --- /dev/null +++ b/test/widgets/dialog_keyboard_shortcuts_test.dart @@ -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); + }); +} diff --git a/tool/format.sh b/tool/format.sh new file mode 100755 index 0000000..b67b452 --- /dev/null +++ b/tool/format.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 프로젝트 전체를 포맷합니다. 추가 인자 없이 실행하면 현재 디렉터리 기준으로 진행됩니다. +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$DIR" + +dart format . + diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8b6d468..0c50753 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,9 @@ #include "generated_plugin_registrant.h" +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b93c4c3..4fc759c 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST