환경 초기화 및 벤더 리포지토리 스켈레톤 도입

This commit is contained in:
JiWoong Sul
2025-09-22 17:38:51 +09:00
commit 5c9de2594a
171 changed files with 13304 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
/// 환경 설정 로더
///
/// - .env.development / .env.production 파일을 로드하여 런타임 설정을 주입한다.
/// - `--dart-define=ENV=production` 형태로 빌드/실행 시 환경을 지정한다.
/// - 주요 키: `API_BASE_URL`, `FEATURE_*` 플래그들.
class Environment {
Environment._();
/// 현재 환경명 (development | production)
static late final String envName;
/// API 서버 베이스 URL
static late final String baseUrl;
/// 프로덕션 여부
static late final bool isProduction;
/// 환경 초기화
///
/// - 기본 환경은 development이며, `ENV` dart-define 으로 변경 가능
/// - 해당 환경의 .env 파일을 로드하고 핵심 값을 추출한다.
static Future<void> initialize() async {
const envFromDefine = String.fromEnvironment('ENV', defaultValue: 'development');
envName = envFromDefine.toLowerCase();
isProduction = envName == 'production';
final fileName = '.env.$envName';
try {
await dotenv.load(fileName: fileName);
} catch (e) {
if (kDebugMode) {
// 개발 편의를 위해 파일 미존재 시 경고만 출력하고 진행
// 실제 배포에서는 파일 존재가 필수다.
// ignore: avoid_print
print('[Environment] $fileName 로드 실패: $e');
}
}
baseUrl = dotenv.maybeGet('API_BASE_URL') ?? 'http://localhost:8080';
}
/// 기능 플래그 조회 (기본 false)
static bool flag(String key, {bool defaultValue = false}) {
final v = dotenv.maybeGet(key);
if (v == null) return defaultValue;
switch (v.trim().toLowerCase()) {
case '1':
case 'y':
case 'yes':
case 'true':
return true;
case '0':
case 'n':
case 'no':
case 'false':
return false;
default:
return defaultValue;
}
}
}

View File

@@ -0,0 +1,171 @@
import 'package:flutter/widgets.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
class AppPageDescriptor {
const AppPageDescriptor({
required this.path,
required this.label,
required this.icon,
required this.summary,
});
final String path;
final String label;
final IconData icon;
final String summary;
}
class AppSectionDescriptor {
const AppSectionDescriptor({required this.label, required this.pages});
final String label;
final List<AppPageDescriptor> pages;
}
const loginRoutePath = '/login';
const dashboardRoutePath = '/dashboard';
const appSections = <AppSectionDescriptor>[
AppSectionDescriptor(
label: '대시보드',
pages: [
AppPageDescriptor(
path: dashboardRoutePath,
label: '대시보드',
icon: LucideIcons.layoutDashboard,
summary: '오늘 입고/출고, 결재 대기, 최근 트랜잭션을 한 화면에서 확인합니다.',
),
],
),
AppSectionDescriptor(
label: '입·출고',
pages: [
AppPageDescriptor(
path: '/inventory/inbound',
label: '입고',
icon: LucideIcons.packagePlus,
summary: '입고 처리 기본정보와 라인 품목을 등록하고 검토합니다.',
),
AppPageDescriptor(
path: '/inventory/outbound',
label: '출고',
icon: LucideIcons.packageMinus,
summary: '출고 품목, 고객사 연결, 상태 변경을 관리합니다.',
),
AppPageDescriptor(
path: '/inventory/rental',
label: '대여',
icon: LucideIcons.handshake,
summary: '대여/반납 구분과 반납예정일을 포함한 대여 흐름입니다.',
),
],
),
AppSectionDescriptor(
label: '마스터',
pages: [
AppPageDescriptor(
path: '/masters/vendors',
label: '제조사 관리',
icon: LucideIcons.factory,
summary: '벤더코드, 명칭, 사용여부 등을 유지합니다.',
),
AppPageDescriptor(
path: '/masters/products',
label: '장비 모델 관리',
icon: LucideIcons.box,
summary: '제품코드, 제조사, 단위 정보를 관리합니다.',
),
AppPageDescriptor(
path: '/masters/warehouses',
label: '입고지 관리',
icon: LucideIcons.warehouse,
summary: '창고 주소와 사용여부를 설정합니다.',
),
AppPageDescriptor(
path: '/masters/customers',
label: '회사 관리',
icon: LucideIcons.building,
summary: '고객사 연락처와 주소 정보를 관리합니다.',
),
AppPageDescriptor(
path: '/masters/users',
label: '사용자 관리',
icon: LucideIcons.users,
summary: '사번, 그룹, 사용여부를 관리합니다.',
),
AppPageDescriptor(
path: '/masters/groups',
label: '그룹 관리',
icon: LucideIcons.layers,
summary: '권한 그룹과 설명, 기본여부를 정의합니다.',
),
AppPageDescriptor(
path: '/masters/menus',
label: '메뉴 관리',
icon: LucideIcons.listTree,
summary: '메뉴 계층과 경로, 노출 순서를 구성합니다.',
),
AppPageDescriptor(
path: '/masters/group-permissions',
label: '그룹 메뉴 권한',
icon: LucideIcons.shieldCheck,
summary: '그룹별 메뉴 CRUD 권한을 설정합니다.',
),
],
),
AppSectionDescriptor(
label: '결재',
pages: [
AppPageDescriptor(
path: '/approvals/requests',
label: '결재 관리',
icon: LucideIcons.fileCheck,
summary: '결재 번호, 상태, 상신자를 관리합니다.',
),
AppPageDescriptor(
path: '/approvals/steps',
label: '결재 단계',
icon: LucideIcons.workflow,
summary: '단계 순서와 승인자 할당을 설정합니다.',
),
AppPageDescriptor(
path: '/approvals/history',
label: '결재 이력',
icon: LucideIcons.history,
summary: '결재 단계별 변경 이력을 조회합니다.',
),
AppPageDescriptor(
path: '/approvals/templates',
label: '결재 템플릿',
icon: LucideIcons.fileSpreadsheet,
summary: '반복되는 결재 흐름을 템플릿으로 관리합니다.',
),
],
),
AppSectionDescriptor(
label: '도구',
pages: [
AppPageDescriptor(
path: '/utilities/postal-search',
label: '우편번호 검색',
icon: LucideIcons.search,
summary: '모달 기반 우편번호 검색 도구입니다.',
),
],
),
AppSectionDescriptor(
label: '보고',
pages: [
AppPageDescriptor(
path: '/reports',
label: '보고서',
icon: LucideIcons.fileDown,
summary: '조건 필터와 PDF/XLSX 다운로드 기능입니다.',
),
],
),
];
List<AppPageDescriptor> get allAppPages => [
for (final section in appSections) ...section.pages,
];

View File

@@ -0,0 +1,60 @@
// ignore_for_file: public_member_api_docs
import 'package:dio/dio.dart';
/// 공통 API 클라이언트 (Dio 래퍼)
/// - 모든 HTTP 호출은 이 클래스를 통해 이루어진다.
/// - BaseURL/타임아웃/인증/로깅/에러 처리 등을 중앙집중화한다.
class ApiClient {
final Dio _dio;
/// 내부에서 사용하는 Dio 인스턴스
/// 외부에서 Dio 직접 사용을 최소화하고, 가능하면 아래 헬퍼 메서드를 사용한다.
Dio get dio => _dio;
ApiClient({required Dio dio}) : _dio = dio;
/// GET 요청 헬퍼
Future<Response<T>> get<T>(
String path, {
Map<String, dynamic>? query,
Options? options,
CancelToken? cancelToken,
}) {
return _dio.get<T>(path, queryParameters: query, options: options, cancelToken: cancelToken);
}
/// POST 요청 헬퍼
Future<Response<T>> post<T>(
String path, {
dynamic data,
Map<String, dynamic>? query,
Options? options,
CancelToken? cancelToken,
}) {
return _dio.post<T>(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken);
}
/// PATCH 요청 헬퍼
Future<Response<T>> patch<T>(
String path, {
dynamic data,
Map<String, dynamic>? query,
Options? options,
CancelToken? cancelToken,
}) {
return _dio.patch<T>(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken);
}
/// DELETE 요청 헬퍼
Future<Response<T>> delete<T>(
String path, {
dynamic data,
Map<String, dynamic>? query,
Options? options,
CancelToken? cancelToken,
}) {
return _dio.delete<T>(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken);
}
}

View File

@@ -0,0 +1,29 @@
// ignore_for_file: public_member_api_docs
import 'package:dio/dio.dart';
/// 인증 인터셉터(스켈레톤)
/// - 요청 전에 Authorization 헤더 주입
/// - 401 수신 시 토큰 갱신 및 원요청 1회 재시도 (구현 예정)
class AuthInterceptor extends Interceptor {
/// TODO: 토큰 저장/조회 서비스 주입 (예: AuthRepository)
AuthInterceptor();
@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';
// }
handler.next(options);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
// TODO: 401 처리 로직(토큰 갱신 → 원요청 재시도) 구현
// if (err.response?.statusCode == 401) { ... }
handler.next(err);
}
}

View File

@@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../features/approvals/history/presentation/pages/approval_history_page.dart';
import '../../features/approvals/request/presentation/pages/approval_request_page.dart';
import '../../features/approvals/step/presentation/pages/approval_step_page.dart';
import '../../features/approvals/template/presentation/pages/approval_template_page.dart';
import '../../features/dashboard/presentation/pages/dashboard_page.dart';
import '../../features/inventory/inbound/presentation/pages/inbound_page.dart';
import '../../features/inventory/outbound/presentation/pages/outbound_page.dart';
import '../../features/inventory/rental/presentation/pages/rental_page.dart';
import '../../features/login/presentation/pages/login_page.dart';
import '../../features/masters/customer/presentation/pages/customer_page.dart';
import '../../features/masters/group/presentation/pages/group_page.dart';
import '../../features/masters/group_permission/presentation/pages/group_permission_page.dart';
import '../../features/masters/menu/presentation/pages/menu_page.dart';
import '../../features/masters/product/presentation/pages/product_page.dart';
import '../../features/masters/user/presentation/pages/user_page.dart';
import '../../features/masters/vendor/presentation/pages/vendor_page.dart';
import '../../features/masters/warehouse/presentation/pages/warehouse_page.dart';
import '../../features/reporting/presentation/pages/reporting_page.dart';
import '../../features/util/postal_search/presentation/pages/postal_search_page.dart';
import '../../widgets/app_shell.dart';
import '../constants/app_sections.dart';
final _rootNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'root');
final appRouter = GoRouter(
navigatorKey: _rootNavigatorKey,
initialLocation: loginRoutePath,
routes: [
GoRoute(path: '/', redirect: (_, __) => loginRoutePath),
GoRoute(
path: loginRoutePath,
name: 'login',
builder: (context, state) => const LoginPage(),
),
ShellRoute(
builder: (context, state, child) =>
AppShell(currentLocation: state.uri.toString(), child: child),
routes: [
GoRoute(
path: dashboardRoutePath,
name: 'dashboard',
builder: (context, state) => const DashboardPage(),
),
GoRoute(
path: '/inventory/inbound',
name: 'inventory-inbound',
builder: (context, state) => const InboundPage(),
),
GoRoute(
path: '/inventory/outbound',
name: 'inventory-outbound',
builder: (context, state) => const OutboundPage(),
),
GoRoute(
path: '/inventory/rental',
name: 'inventory-rental',
builder: (context, state) => const RentalPage(),
),
GoRoute(
path: '/masters/vendors',
name: 'masters-vendors',
builder: (context, state) => const VendorPage(),
),
GoRoute(
path: '/masters/products',
name: 'masters-products',
builder: (context, state) => const ProductPage(),
),
GoRoute(
path: '/masters/warehouses',
name: 'masters-warehouses',
builder: (context, state) => const WarehousePage(),
),
GoRoute(
path: '/masters/customers',
name: 'masters-customers',
builder: (context, state) => const CustomerPage(),
),
GoRoute(
path: '/masters/users',
name: 'masters-users',
builder: (context, state) => const UserPage(),
),
GoRoute(
path: '/masters/groups',
name: 'masters-groups',
builder: (context, state) => const GroupPage(),
),
GoRoute(
path: '/masters/menus',
name: 'masters-menus',
builder: (context, state) => const MenuPage(),
),
GoRoute(
path: '/masters/group-permissions',
name: 'masters-group-permissions',
builder: (context, state) => const GroupPermissionPage(),
),
GoRoute(
path: '/approvals/requests',
name: 'approvals-requests',
builder: (context, state) => const ApprovalRequestPage(),
),
GoRoute(
path: '/approvals/steps',
name: 'approvals-steps',
builder: (context, state) => const ApprovalStepPage(),
),
GoRoute(
path: '/approvals/history',
name: 'approvals-history',
builder: (context, state) => const ApprovalHistoryPage(),
),
GoRoute(
path: '/approvals/templates',
name: 'approvals-templates',
builder: (context, state) => const ApprovalTemplatePage(),
),
GoRoute(
path: '/utilities/postal-search',
name: 'utilities-postal-search',
builder: (context, state) => const PostalSearchPage(),
),
GoRoute(
path: '/reports',
name: 'reports',
builder: (context, state) => const ReportingPage(),
),
],
),
],
);