주석화 진행상황 정리하고 핵심 모듈에 한글 주석 추가

This commit is contained in:
JiWoong Sul
2025-09-29 19:39:35 +09:00
parent 9467b8c87f
commit 47c87dc118
82 changed files with 596 additions and 5 deletions

View File

@@ -49,3 +49,13 @@
세부 계획은 doc/test_commenting_plan.md 참고 세부 계획은 doc/test_commenting_plan.md 참고
## Core/Widgets 주석화 ## Core/Widgets 주석화
세부 계획은 doc/core_commenting_plan.md 참고 세부 계획은 doc/core_commenting_plan.md 참고
### 진행 현황 메모
- [x] `responsive.dart` 단말 분기/가시성 헬퍼 주석 추가
- [x] `feedback.dart` 토스트·스켈레톤 메서드 용도 설명 보강
- [x] `form_field.dart` 필수/에러/캡션 속성 주석화
- [x] `lib/features/approvals` 컨트롤러/페이지/공유 위젯 주석 보강
- [x] `lib/features/approvals/data/**` 원격 저장소·DTO 변환 로직 주석화
- [x] `lib/features/inventory` 공유 카탈로그/자동완성 위젯 주석 보강
- [x] `lib/features/reporting` 보고서 페이지 필터/상태 주석 보강
- [x] `lib/features/masters/**` DTO/Repository/Controller/Page 주석 보강

View File

@@ -5,12 +5,34 @@
|----------|------|----------------| |----------|------|----------------|
| 1 | `lib/core/network/api_client.dart` | API 호출, 에러 매핑, 인터셉터 연결 | | 1 | `lib/core/network/api_client.dart` | API 호출, 에러 매핑, 인터셉터 연결 |
| 1 | `lib/core/network/api_error.dart` | 에러 모델/매퍼 설명 | | 1 | `lib/core/network/api_error.dart` | 에러 모델/매퍼 설명 |
| 1 | `lib/core/network/interceptors/auth_interceptor.dart` | 토큰 갱신 흐름, 재시도 로직 |
| 1 | `lib/core/permissions/permission_manager.dart` | 권한 검사/Scope 동작 | | 1 | `lib/core/permissions/permission_manager.dart` | 권한 검사/Scope 동작 |
| 2 | `lib/core/config/environment.dart` | env 초기화 흐름, 추가 설명 필요시 | | 2 | `lib/core/config/environment.dart` | env 초기화 흐름, 추가 설명 필요시 |
| 2 | `lib/core/theme/superport_shad_theme.dart` | 테마 팩토리 설명 | | 2 | `lib/core/theme/superport_shad_theme.dart` | 테마 팩토리 설명 |
| 2 | `lib/core/routing/app_router.dart` | 라우터 설정 요약 | | 2 | `lib/core/routing/app_router.dart` | 라우터 설정 요약 |
| 3 | `lib/widgets/components/**` (이미 일부 주석 있음) | 추가 주석 대상 확인 | | 3 | `lib/widgets/components/**` (이미 일부 주석 있음) | 추가 주석 대상 확인 |
## 진행 현황
- [x] `lib/core/network/interceptors/auth_interceptor.dart` 토큰 갱신/재시도 흐름 문서화
- [x] `lib/core/services/token_storage.dart` 토큰 저장/초기화 Doc 주석 보강
- [x] `lib/core/permissions/permission_manager.dart` 오버라이드 처리/Scope 조회 주석 추가
- [x] `lib/core/config/environment.dart` 권한 맵 로딩/조회 인라인 설명 보강
- [x] `lib/core/theme/superport_shad_theme.dart` 라이트/다크 컬러 스킴 및 상태 배지 설명 추가
- [x] `lib/core/routing/app_router.dart` ShellRoute 구조와 라우트 명세 주석화
- [x] `lib/widgets/components/superport_table.dart` 정렬/페이지네이션 모델 설명 및 fromCells 생성자 주석 추가
- [x] `lib/widgets/components/superport_dialog.dart` show 헬퍼 주석, 편의 함수 한글 설명 갱신
- [x] `lib/widgets/components/filter_bar.dart` 버튼 활성 상태 게터 주석 보강
- [x] `lib/widgets/components/keyboard_shortcuts.dart` 다이얼로그 키보드 단축키 역할 설명 추가
- [x] `lib/widgets/components/responsive.dart` 분기 헬퍼/레이아웃 위젯 주석 추가
- [x] `lib/widgets/components/feedback.dart` 토스트·스켈레톤 메서드 용도 설명
- [x] `lib/widgets/components/form_field.dart` 필드 속성/멀티라인 설정 주석화
## 남은 작업 (Tasks)
- [x] `lib/widgets/components/responsive.dart` 전역 함수/클래스에 환경별 역할 Doc 주석 추가
- [x] `lib/widgets/components/feedback.dart` 토스트/스켈레톤 정적 메서드 용도 요약 (필요 시 예시 포함)
- [x] `lib/widgets/components/form_field.dart` 필수/유효성 문구 속성 설명 재검토(다단 필드 지원 여부 언급)
- [x] 주석화 완료 파일 재검토 후 동일 패턴으로 `doc/commenting_plan.md`에 반영 여부 확인
## 진행 순서 ## 진행 순서
1. `core/network``core/permissions` 1. `core/network``core/permissions`
2. `core/theme`, `core/routing`, `core/config` 2. `core/theme`, `core/routing`, `core/config`

View File

@@ -18,6 +18,7 @@ class Environment {
/// 프로덕션 여부 /// 프로덕션 여부
static late final bool isProduction; static late final bool isProduction;
/// 환경 변수에서 파싱한 리소스별 권한 집합.
static final Map<String, Set<String>> _permissions = {}; static final Map<String, Set<String>> _permissions = {};
/// 환경 초기화 /// 환경 초기화
@@ -74,6 +75,7 @@ class Environment {
} }
} }
/// `.env` 파일에서 `PERMISSION__*` 키를 파싱해 권한 맵을 구성한다.
static void _loadPermissions() { static void _loadPermissions() {
_permissions.clear(); _permissions.clear();
for (final entry in dotenv.env.entries) { for (final entry in dotenv.env.entries) {
@@ -82,6 +84,7 @@ class Environment {
continue; continue;
} }
final resource = entry.key.substring(prefix.length).toLowerCase(); final resource = entry.key.substring(prefix.length).toLowerCase();
// 콤마 구분 문자열을 소문자/trim 처리해 비교를 일관되게 맞춘다.
final values = entry.value final values = entry.value
.split(',') .split(',')
.map((token) => token.trim().toLowerCase()) .map((token) => token.trim().toLowerCase())
@@ -91,12 +94,14 @@ class Environment {
} }
} }
/// 환경에 설정된 권한이 있는 경우 해당 액션 허용 여부를 반환한다.
static bool hasPermission(String resource, String action) { static bool hasPermission(String resource, String action) {
final actions = _permissions[resource.toLowerCase()]; final actions = _permissions[resource.toLowerCase()];
if (actions == null || actions.isEmpty) { if (actions == null || actions.isEmpty) {
return true; return true;
} }
if (actions.contains('all')) { if (actions.contains('all')) {
// all 키워드는 모든 액션 허용을 의미한다.
return true; return true;
} }
return actions.contains(action.toLowerCase()); return actions.contains(action.toLowerCase());

View File

@@ -1,17 +1,21 @@
// ignore_for_file: public_member_api_docs
import 'dart:async'; import 'dart:async';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../services/token_storage.dart'; import '../../services/token_storage.dart';
/// 갱신 토큰을 기반으로 새로운 토큰 쌍을 받아오는 비동기 콜백 시그니처.
typedef RefreshTokenCallback = Future<TokenPair?> Function(); typedef RefreshTokenCallback = Future<TokenPair?> Function();
/// 액세스/리프레시 토큰 값을 함께 보관하기 위한 불변 모델.
class TokenPair { class TokenPair {
/// [accessToken], [refreshToken]을 모두 전달받아 초기화한다.
const TokenPair({required this.accessToken, required this.refreshToken}); const TokenPair({required this.accessToken, required this.refreshToken});
/// 인증 헤더에 사용되는 액세스 토큰 문자열.
final String accessToken; final String accessToken;
/// 토큰 재발급 요청에 전달되는 리프레시 토큰 문자열.
final String refreshToken; final String refreshToken;
} }
@@ -28,11 +32,17 @@ class AuthInterceptor extends Interceptor {
final TokenStorage _tokenStorage; final TokenStorage _tokenStorage;
final Dio _dio; final Dio _dio;
/// 서버에 토큰 재발급을 요청하는 콜백. null이면 갱신을 시도하지 않는다.
final RefreshTokenCallback? onRefresh; final RefreshTokenCallback? onRefresh;
/// 동시에 들어온 요청을 순차적으로 처리하기 위한 대기열.
final List<Completer<void>> _refreshQueue = []; final List<Completer<void>> _refreshQueue = [];
/// 현재 토큰 갱신 중인지 여부.
bool _isRefreshing = false; bool _isRefreshing = false;
/// 요청 직전에 저장된 액세스 토큰을 Authorization 헤더에 주입한다.
@override @override
Future<void> onRequest( Future<void> onRequest(
RequestOptions options, RequestOptions options,
@@ -45,6 +55,7 @@ class AuthInterceptor extends Interceptor {
handler.next(options); handler.next(options);
} }
/// 401 응답을 감지하면 토큰을 갱신하고 동일 요청을 한 번 재시도한다.
@override @override
Future<void> onError( Future<void> onError(
DioException err, DioException err,
@@ -69,12 +80,14 @@ class AuthInterceptor extends Interceptor {
} }
} }
/// 토큰 갱신을 시도해야 하는 상황인지 판별한다.
bool _shouldAttemptRefresh(DioException err) { bool _shouldAttemptRefresh(DioException err) {
return onRefresh != null && return onRefresh != null &&
err.response?.statusCode == 401 && err.response?.statusCode == 401 &&
err.requestOptions.extra['__retry'] != true; err.requestOptions.extra['__retry'] != true;
} }
/// 토큰을 갱신하고 대기 중인 요청을 깨운다.
Future<void> _refreshToken() async { Future<void> _refreshToken() async {
if (_isRefreshing) { if (_isRefreshing) {
final completer = Completer<void>(); final completer = Completer<void>();
@@ -107,6 +120,7 @@ class AuthInterceptor extends Interceptor {
} }
} }
/// 최신 토큰을 헤더에 주입해 한 번만 동일 요청을 재시도한다.
Future<Response<dynamic>> _retry(RequestOptions requestOptions) async { Future<Response<dynamic>> _retry(RequestOptions requestOptions) async {
final token = await _tokenStorage.readAccessToken(); final token = await _tokenStorage.readAccessToken();
if (token != null && token.isNotEmpty) { if (token != null && token.isNotEmpty) {
@@ -119,6 +133,7 @@ class AuthInterceptor extends Interceptor {
} }
} }
/// 토큰 재발급 실패 시 재시도 루프를 중단하기 위해 사용하는 내부 예외.
class _RefreshFailedException implements Exception { class _RefreshFailedException implements Exception {
const _RefreshFailedException(); const _RefreshFailedException();
} }

View File

@@ -13,12 +13,14 @@ class PermissionManager extends ChangeNotifier {
} }
} }
/// 리소스별 임시 권한 집합을 보관한다.
final Map<String, Set<PermissionAction>> _overrides = {}; final Map<String, Set<PermissionAction>> _overrides = {};
/// 지정한 리소스/행동이 허용되는지 여부를 반환한다. /// 지정한 리소스/행동이 허용되는지 여부를 반환한다.
bool can(String resource, PermissionAction action) { bool can(String resource, PermissionAction action) {
final override = _overrides[resource]; final override = _overrides[resource];
if (override != null) { if (override != null) {
// View 권한은 최소 접근을 허용하기 위해 별도로 처리한다.
if (override.contains(PermissionAction.view) && if (override.contains(PermissionAction.view) &&
action == PermissionAction.view) { action == PermissionAction.view) {
return true; return true;
@@ -45,6 +47,7 @@ class PermissionScope extends InheritedNotifier<PermissionManager> {
required super.child, required super.child,
}) : super(notifier: manager); }) : super(notifier: manager);
/// 현재 빌드 컨텍스트에서 [PermissionManager]를 조회한다.
static PermissionManager of(BuildContext context) { static PermissionManager of(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<PermissionScope>(); final scope = context.dependOnInheritedWidgetOfExactType<PermissionScope>();
assert( assert(

View File

@@ -27,6 +27,10 @@ import '../constants/app_sections.dart';
final _rootNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'root'); final _rootNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'root');
/// 애플리케이션 전체 라우팅 구성을 담당하는 GoRouter 인스턴스. /// 애플리케이션 전체 라우팅 구성을 담당하는 GoRouter 인스턴스.
///
/// - 로그인 전용 라우트는 루트(primary) 네비게이터에서 처리한다.
/// - 로그인 이후 화면은 [ShellRoute] 하위에서 `AppShell` 레이아웃을 공유한다.
/// - 각 기능 모듈은 고유 name/path를 가지며 `GoRouter` 딥링크와 연결된다.
final appRouter = GoRouter( final appRouter = GoRouter(
navigatorKey: _rootNavigatorKey, navigatorKey: _rootNavigatorKey,
initialLocation: loginRoutePath, initialLocation: loginRoutePath,

View File

@@ -4,14 +4,19 @@ import 'token_storage_stub.dart'
/// 액세스/리프레시 토큰을 안전하게 보관하는 스토리지 인터페이스. /// 액세스/리프레시 토큰을 안전하게 보관하는 스토리지 인터페이스.
abstract class TokenStorage { abstract class TokenStorage {
/// 액세스 토큰을 저장한다. null을 전달하면 기존 값을 제거한다.
Future<void> writeAccessToken(String? token); Future<void> writeAccessToken(String? token);
/// 저장된 액세스 토큰을 읽어온다. 없으면 null을 반환한다.
Future<String?> readAccessToken(); Future<String?> readAccessToken();
/// 리프레시 토큰을 저장한다. null이면 값을 삭제한다.
Future<void> writeRefreshToken(String? token); Future<void> writeRefreshToken(String? token);
/// 저장된 리프레시 토큰을 읽어온다. 없으면 null을 반환한다.
Future<String?> readRefreshToken(); Future<String?> readRefreshToken();
/// 저장 중인 모든 토큰 정보를 초기화한다.
Future<void> clear(); Future<void> clear();
} }

View File

@@ -2,9 +2,12 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'token_storage.dart'; import 'token_storage.dart';
/// 안전한 스토리지에 저장할 액세스 토큰 키.
const _kAccessTokenKey = 'access_token'; const _kAccessTokenKey = 'access_token';
/// 안전한 스토리지에 저장할 리프레시 토큰 키.
const _kRefreshTokenKey = 'refresh_token'; const _kRefreshTokenKey = 'refresh_token';
/// 모바일/데스크톱에서 [FlutterSecureStorage]를 사용하는 토큰 스토리지를 생성한다.
TokenStorage buildTokenStorage() { TokenStorage buildTokenStorage() {
const storage = FlutterSecureStorage( const storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true), aOptions: AndroidOptions(encryptedSharedPreferences: true),
@@ -16,6 +19,7 @@ TokenStorage buildTokenStorage() {
return _SecureTokenStorage(storage); return _SecureTokenStorage(storage);
} }
/// [FlutterSecureStorage] 기반 토큰 스토리지 구현체.
class _SecureTokenStorage implements TokenStorage { class _SecureTokenStorage implements TokenStorage {
const _SecureTokenStorage(this._storage); const _SecureTokenStorage(this._storage);

View File

@@ -1,7 +1,9 @@
import 'token_storage.dart'; import 'token_storage.dart';
/// 현재 플랫폼에서 지원되는 토큰 스토리지가 없을 때 사용하는 기본 구현을 생성한다.
TokenStorage buildTokenStorage() => _UnsupportedTokenStorage(); TokenStorage buildTokenStorage() => _UnsupportedTokenStorage();
/// 토큰 저장 기능이 제공되지 않는 환경에서 호출될 때 예외를 던지는 스텁.
class _UnsupportedTokenStorage implements TokenStorage { class _UnsupportedTokenStorage implements TokenStorage {
Never _unsupported() { Never _unsupported() {
throw UnsupportedError('TokenStorage is not supported on this platform.'); throw UnsupportedError('TokenStorage is not supported on this platform.');

View File

@@ -3,11 +3,15 @@ import 'dart:html' as html;
import 'token_storage.dart'; import 'token_storage.dart';
/// 웹 로컬스토리지에 저장할 액세스 토큰 키.
const _kAccessTokenKey = 'access_token'; const _kAccessTokenKey = 'access_token';
/// 웹 로컬스토리지에 저장할 리프레시 토큰 키.
const _kRefreshTokenKey = 'refresh_token'; const _kRefreshTokenKey = 'refresh_token';
/// 웹 환경에서 로컬스토리지를 활용하는 토큰 스토리지를 생성한다.
TokenStorage buildTokenStorage() => _WebTokenStorage(html.window.localStorage); TokenStorage buildTokenStorage() => _WebTokenStorage(html.window.localStorage);
/// 브라우저 로컬스토리지에 토큰을 저장하는 구현체.
class _WebTokenStorage implements TokenStorage { class _WebTokenStorage implements TokenStorage {
const _WebTokenStorage(this._storage); const _WebTokenStorage(this._storage);

View File

@@ -5,6 +5,7 @@ import 'package:shadcn_ui/shadcn_ui.dart';
class SuperportShadTheme { class SuperportShadTheme {
const SuperportShadTheme._(); const SuperportShadTheme._();
/// 브랜드 색상을 기준으로 정의한 주 색상 값.
static const Color primaryColor = Color(0xFF1B4F87); static const Color primaryColor = Color(0xFF1B4F87);
static const Color successColor = Color(0xFF2E8B57); static const Color successColor = Color(0xFF2E8B57);
static const Color warningColor = Color(0xFFFFC107); static const Color warningColor = Color(0xFFFFC107);
@@ -16,6 +17,7 @@ class SuperportShadTheme {
return ShadThemeData( return ShadThemeData(
brightness: Brightness.light, brightness: Brightness.light,
colorScheme: ShadColorScheme( colorScheme: ShadColorScheme(
// 라이트 모드 기본 배경/텍스트 대비값 설정.
background: Color(0xFFFFFFFF), background: Color(0xFFFFFFFF),
foreground: Color(0xFF09090B), foreground: Color(0xFF09090B),
card: Color(0xFFFFFFFF), card: Color(0xFFFFFFFF),
@@ -94,6 +96,7 @@ class SuperportShadTheme {
return ShadThemeData( return ShadThemeData(
brightness: Brightness.dark, brightness: Brightness.dark,
colorScheme: ShadColorScheme( colorScheme: ShadColorScheme(
// 다크 모드 대비를 위해 어두운 배경/밝은 텍스트 조합을 사용한다.
background: Color(0xFF09090B), background: Color(0xFF09090B),
foreground: Color(0xFFFAFAFA), foreground: Color(0xFFFAFAFA),
card: Color(0xFF09090B), card: Color(0xFF09090B),
@@ -168,6 +171,7 @@ class SuperportShadTheme {
} }
/// 상태 텍스트 배경을 위한 데코레이션을 반환한다. /// 상태 텍스트 배경을 위한 데코레이션을 반환한다.
/// 상태 문자열을 기반으로 배경/테두리 색을 선택해 상태 배지를 표현한다.
static BoxDecoration statusDecoration(String status) { static BoxDecoration statusDecoration(String status) {
Color backgroundColor; Color backgroundColor;
Color borderColor; Color borderColor;

View File

@@ -3,6 +3,10 @@ import 'package:superport_v2/core/common/utils/json_utils.dart';
import '../../domain/entities/approval.dart'; import '../../domain/entities/approval.dart';
/// 결재 API 응답을 표현하는 DTO.
///
/// - 원본 JSON 형식을 유지하면서 도메인 엔티티 변환을 제공한다.
/// - 일부 필드는 누락 가능성을 고려하여 기본값을 지정한다.
class ApprovalDto { class ApprovalDto {
ApprovalDto({ ApprovalDto({
this.id, this.id,
@@ -38,6 +42,7 @@ class ApprovalDto {
final DateTime? createdAt; final DateTime? createdAt;
final DateTime? updatedAt; final DateTime? updatedAt;
/// API 응답 JSON을 [ApprovalDto]로 변환한다.
factory ApprovalDto.fromJson(Map<String, dynamic> json) { factory ApprovalDto.fromJson(Map<String, dynamic> json) {
return ApprovalDto( return ApprovalDto(
id: json['id'] as int?, id: json['id'] as int?,
@@ -74,6 +79,7 @@ class ApprovalDto {
); );
} }
/// DTO를 도메인 [Approval] 엔티티로 변환한다.
Approval toEntity() => Approval( Approval toEntity() => Approval(
id: id, id: id,
approvalNo: approvalNo, approvalNo: approvalNo,
@@ -92,6 +98,7 @@ class ApprovalDto {
updatedAt: updatedAt, updatedAt: updatedAt,
); );
/// 페이징 응답을 파싱해 [PaginatedResult]로 변환한다.
static PaginatedResult<Approval> parsePaginated(Map<String, dynamic>? json) { static PaginatedResult<Approval> parsePaginated(Map<String, dynamic>? json) {
final rawItems = JsonUtils.extractList(json, keys: const ['items']); final rawItems = JsonUtils.extractList(json, keys: const ['items']);
final items = rawItems final items = rawItems
@@ -107,6 +114,7 @@ class ApprovalDto {
} }
} }
/// 결재 상태(Status) DTO.
class ApprovalStatusDto { class ApprovalStatusDto {
ApprovalStatusDto({required this.id, required this.name, this.color}); ApprovalStatusDto({required this.id, required this.name, this.color});
@@ -122,9 +130,11 @@ class ApprovalStatusDto {
); );
} }
/// DTO를 [ApprovalStatus]로 변환한다.
ApprovalStatus toEntity() => ApprovalStatus(id: id, name: name, color: color); ApprovalStatus toEntity() => ApprovalStatus(id: id, name: name, color: color);
} }
/// 결재 요청자 DTO.
class ApprovalRequesterDto { class ApprovalRequesterDto {
ApprovalRequesterDto({ ApprovalRequesterDto({
required this.id, required this.id,
@@ -144,10 +154,12 @@ class ApprovalRequesterDto {
); );
} }
/// DTO를 [ApprovalRequester]로 변환한다.
ApprovalRequester toEntity() => ApprovalRequester toEntity() =>
ApprovalRequester(id: id, employeeNo: employeeNo, name: name); ApprovalRequester(id: id, employeeNo: employeeNo, name: name);
} }
/// 결재 승인자 DTO.
class ApprovalApproverDto { class ApprovalApproverDto {
ApprovalApproverDto({ ApprovalApproverDto({
required this.id, required this.id,
@@ -167,10 +179,12 @@ class ApprovalApproverDto {
); );
} }
/// DTO를 [ApprovalApprover]로 변환한다.
ApprovalApprover toEntity() => ApprovalApprover toEntity() =>
ApprovalApprover(id: id, employeeNo: employeeNo, name: name); ApprovalApprover(id: id, employeeNo: employeeNo, name: name);
} }
/// 결재 단계 DTO.
class ApprovalStepDto { class ApprovalStepDto {
ApprovalStepDto({ ApprovalStepDto({
this.id, this.id,
@@ -206,6 +220,7 @@ class ApprovalStepDto {
); );
} }
/// DTO를 [ApprovalStep]으로 변환한다.
ApprovalStep toEntity() => ApprovalStep( ApprovalStep toEntity() => ApprovalStep(
id: id, id: id,
stepOrder: stepOrder, stepOrder: stepOrder,
@@ -217,6 +232,7 @@ class ApprovalStepDto {
); );
} }
/// 결재 이력 DTO.
class ApprovalHistoryDto { class ApprovalHistoryDto {
ApprovalHistoryDto({ ApprovalHistoryDto({
this.id, this.id,
@@ -258,6 +274,7 @@ class ApprovalHistoryDto {
); );
} }
/// DTO를 [ApprovalHistory]로 변환한다.
ApprovalHistory toEntity() => ApprovalHistory( ApprovalHistory toEntity() => ApprovalHistory(
id: id, id: id,
action: action.toEntity(), action: action.toEntity(),
@@ -269,6 +286,7 @@ class ApprovalHistoryDto {
); );
} }
/// 결재 행위(Action) DTO.
class ApprovalActionDto { class ApprovalActionDto {
ApprovalActionDto({required this.id, required this.name}); ApprovalActionDto({required this.id, required this.name});
@@ -282,9 +300,11 @@ class ApprovalActionDto {
); );
} }
/// DTO를 [ApprovalAction]으로 변환한다.
ApprovalAction toEntity() => ApprovalAction(id: id, name: name); ApprovalAction toEntity() => ApprovalAction(id: id, name: name);
} }
/// 문자열/DateTime 입력을 DateTime으로 변환한다.
DateTime? _parseDate(Object? value) { DateTime? _parseDate(Object? value) {
if (value == null) return null; if (value == null) return null;
if (value is DateTime) return value; if (value is DateTime) return value;

View File

@@ -3,6 +3,7 @@ import 'package:superport_v2/core/common/utils/json_utils.dart';
import '../../domain/entities/approval_template.dart'; import '../../domain/entities/approval_template.dart';
/// 결재 템플릿 API 응답을 표현하는 DTO.
class ApprovalTemplateDto { class ApprovalTemplateDto {
ApprovalTemplateDto({ ApprovalTemplateDto({
required this.id, required this.id,
@@ -28,6 +29,7 @@ class ApprovalTemplateDto {
final DateTime? updatedAt; final DateTime? updatedAt;
final List<ApprovalTemplateStepDto> steps; final List<ApprovalTemplateStepDto> steps;
/// JSON을 [ApprovalTemplateDto]로 파싱한다.
factory ApprovalTemplateDto.fromJson(Map<String, dynamic> json) { factory ApprovalTemplateDto.fromJson(Map<String, dynamic> json) {
return ApprovalTemplateDto( return ApprovalTemplateDto(
id: json['id'] as int? ?? 0, id: json['id'] as int? ?? 0,
@@ -50,6 +52,7 @@ class ApprovalTemplateDto {
); );
} }
/// DTO를 [ApprovalTemplate]으로 변환한다.
ApprovalTemplate toEntity({bool includeSteps = true}) { ApprovalTemplate toEntity({bool includeSteps = true}) {
return ApprovalTemplate( return ApprovalTemplate(
id: id, id: id,
@@ -65,6 +68,7 @@ class ApprovalTemplateDto {
); );
} }
/// 페이징 응답을 파싱해 [PaginatedResult]를 반환한다.
static PaginatedResult<ApprovalTemplate> parsePaginated( static PaginatedResult<ApprovalTemplate> parsePaginated(
Map<String, dynamic>? json, { Map<String, dynamic>? json, {
bool includeSteps = false, bool includeSteps = false,
@@ -83,6 +87,7 @@ class ApprovalTemplateDto {
} }
} }
/// 템플릿 작성자 DTO.
class ApprovalTemplateAuthorDto { class ApprovalTemplateAuthorDto {
ApprovalTemplateAuthorDto({ ApprovalTemplateAuthorDto({
required this.id, required this.id,
@@ -102,11 +107,13 @@ class ApprovalTemplateAuthorDto {
); );
} }
/// DTO를 [ApprovalTemplateAuthor]로 변환한다.
ApprovalTemplateAuthor toEntity() { ApprovalTemplateAuthor toEntity() {
return ApprovalTemplateAuthor(id: id, employeeNo: employeeNo, name: name); return ApprovalTemplateAuthor(id: id, employeeNo: employeeNo, name: name);
} }
} }
/// 템플릿 단계 DTO.
class ApprovalTemplateStepDto { class ApprovalTemplateStepDto {
ApprovalTemplateStepDto({ ApprovalTemplateStepDto({
this.id, this.id,
@@ -131,6 +138,7 @@ class ApprovalTemplateStepDto {
); );
} }
/// DTO를 [ApprovalTemplateStep]으로 변환한다.
ApprovalTemplateStep toEntity() { ApprovalTemplateStep toEntity() {
return ApprovalTemplateStep( return ApprovalTemplateStep(
id: id, id: id,
@@ -141,6 +149,7 @@ class ApprovalTemplateStepDto {
} }
} }
/// 템플릿 승인자 DTO.
class ApprovalTemplateApproverDto { class ApprovalTemplateApproverDto {
ApprovalTemplateApproverDto({ ApprovalTemplateApproverDto({
required this.id, required this.id,
@@ -160,11 +169,13 @@ class ApprovalTemplateApproverDto {
); );
} }
/// DTO를 [ApprovalTemplateApprover]로 변환한다.
ApprovalTemplateApprover toEntity() { ApprovalTemplateApprover toEntity() {
return ApprovalTemplateApprover(id: id, employeeNo: employeeNo, name: name); return ApprovalTemplateApprover(id: id, employeeNo: employeeNo, name: name);
} }
} }
/// 문자열/DateTime을 파싱해 [DateTime]으로 반환한다.
DateTime? _parseDate(Object? value) { DateTime? _parseDate(Object? value) {
if (value == null) return null; if (value == null) return null;
if (value is DateTime) return value; if (value is DateTime) return value;

View File

@@ -6,6 +6,10 @@ import '../../domain/entities/approval.dart';
import '../../domain/repositories/approval_repository.dart'; import '../../domain/repositories/approval_repository.dart';
import '../dtos/approval_dto.dart'; import '../dtos/approval_dto.dart';
/// 결재 API 엔드포인트를 호출하는 원격 저장소 구현체.
///
/// - 모든 요청은 [ApiClient]를 통해 인증/에러 매핑을 공유한다.
/// - 엔티티 변환은 DTO 계층에 위임한다.
class ApprovalRepositoryRemote implements ApprovalRepository { class ApprovalRepositoryRemote implements ApprovalRepository {
ApprovalRepositoryRemote({required ApiClient apiClient}) : _api = apiClient; ApprovalRepositoryRemote({required ApiClient apiClient}) : _api = apiClient;
@@ -13,6 +17,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
static const _basePath = '/approvals'; static const _basePath = '/approvals';
/// 결재 목록을 조회한다. 필터 조건이 없으면 최신순 페이지를 반환한다.
@override @override
Future<PaginatedResult<Approval>> list({ Future<PaginatedResult<Approval>> list({
int page = 1, int page = 1,
@@ -41,6 +46,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
return ApprovalDto.parsePaginated(response.data ?? const {}); return ApprovalDto.parsePaginated(response.data ?? const {});
} }
/// 결재 상세를 조회한다. 단계/이력 포함 여부를 쿼리 파라미터로 제어한다.
@override @override
Future<Approval> fetchDetail( Future<Approval> fetchDetail(
int id, { int id, {
@@ -59,6 +65,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
return ApprovalDto.fromJson(data).toEntity(); return ApprovalDto.fromJson(data).toEntity();
} }
/// 활성화된 결재 행위 목록을 조회한다.
@override @override
Future<List<ApprovalAction>> listActions({bool activeOnly = true}) async { Future<List<ApprovalAction>> listActions({bool activeOnly = true}) async {
final response = await _api.get<Map<String, dynamic>>( final response = await _api.get<Map<String, dynamic>>(
@@ -74,6 +81,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
return items; return items;
} }
/// 결재 단계 행위를 수행하고 업데이트된 결재 정보를 반환한다.
@override @override
Future<Approval> performStepAction(ApprovalStepActionInput input) async { Future<Approval> performStepAction(ApprovalStepActionInput input) async {
final response = await _api.post<Map<String, dynamic>>( final response = await _api.post<Map<String, dynamic>>(
@@ -90,6 +98,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
return ApprovalDto.fromJson(approvalJson).toEntity(); return ApprovalDto.fromJson(approvalJson).toEntity();
} }
/// 결재 단계들을 일괄로 생성하거나 재배치한다.
@override @override
Future<Approval> assignSteps(ApprovalStepAssignmentInput input) async { Future<Approval> assignSteps(ApprovalStepAssignmentInput input) async {
final response = await _api.post<Map<String, dynamic>>( final response = await _api.post<Map<String, dynamic>>(
@@ -106,6 +115,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
return ApprovalDto.fromJson(approvalJson).toEntity(); return ApprovalDto.fromJson(approvalJson).toEntity();
} }
/// 새로운 결재를 생성한다.
@override @override
Future<Approval> create(ApprovalInput input) async { Future<Approval> create(ApprovalInput input) async {
final response = await _api.post<Map<String, dynamic>>( final response = await _api.post<Map<String, dynamic>>(
@@ -117,6 +127,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
return ApprovalDto.fromJson(data).toEntity(); return ApprovalDto.fromJson(data).toEntity();
} }
/// 결재 기본 정보를 수정한다.
@override @override
Future<Approval> update(int id, ApprovalInput input) async { Future<Approval> update(int id, ApprovalInput input) async {
final response = await _api.patch<Map<String, dynamic>>( final response = await _api.patch<Map<String, dynamic>>(
@@ -128,11 +139,13 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
return ApprovalDto.fromJson(data).toEntity(); return ApprovalDto.fromJson(data).toEntity();
} }
/// 결재를 삭제(비활성화)한다.
@override @override
Future<void> delete(int id) async { Future<void> delete(int id) async {
await _api.delete<void>('$_basePath/$id'); await _api.delete<void>('$_basePath/$id');
} }
/// 삭제된 결재를 복구한다.
@override @override
Future<Approval> restore(int id) async { Future<Approval> restore(int id) async {
final response = await _api.post<Map<String, dynamic>>( final response = await _api.post<Map<String, dynamic>>(
@@ -143,6 +156,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
return ApprovalDto.fromJson(data).toEntity(); return ApprovalDto.fromJson(data).toEntity();
} }
/// 결재 단계/행위 응답에서 결재 객체 JSON을 추출한다.
Map<String, dynamic>? _extractApprovalFromActionResponse( Map<String, dynamic>? _extractApprovalFromActionResponse(
Map<String, dynamic> body, Map<String, dynamic> body,
) { ) {

View File

@@ -6,6 +6,10 @@ import '../../domain/entities/approval_template.dart';
import '../../domain/repositories/approval_template_repository.dart'; import '../../domain/repositories/approval_template_repository.dart';
import '../dtos/approval_template_dto.dart'; import '../dtos/approval_template_dto.dart';
/// 결재 템플릿 관련 API를 호출하는 원격 저장소.
///
/// - 템플릿/단계 CRUD와 복구 API를 캡슐화한다.
/// - 단계 등록은 별도 엔드포인트로 처리한다.
class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository { class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
ApprovalTemplateRepositoryRemote({required ApiClient apiClient}) ApprovalTemplateRepositoryRemote({required ApiClient apiClient})
: _api = apiClient; : _api = apiClient;
@@ -14,6 +18,7 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
static const _basePath = '/approval-templates'; static const _basePath = '/approval-templates';
/// 결재 템플릿 목록을 조회한다. 검색/활성 여부 필터를 지원한다.
@override @override
Future<PaginatedResult<ApprovalTemplate>> list({ Future<PaginatedResult<ApprovalTemplate>> list({
int page = 1, int page = 1,
@@ -34,6 +39,7 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
return ApprovalTemplateDto.parsePaginated(response.data); return ApprovalTemplateDto.parsePaginated(response.data);
} }
/// 템플릿 상세 정보를 조회한다. 필요 시 단계 포함 여부를 지정한다.
@override @override
Future<ApprovalTemplate> fetchDetail( Future<ApprovalTemplate> fetchDetail(
int id, { int id, {
@@ -50,6 +56,7 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
).toEntity(includeSteps: includeSteps); ).toEntity(includeSteps: includeSteps);
} }
/// 템플릿을 생성하고 필요하면 단계까지 함께 등록한다.
@override @override
Future<ApprovalTemplate> create( Future<ApprovalTemplate> create(
ApprovalTemplateInput input, { ApprovalTemplateInput input, {
@@ -70,6 +77,7 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
return fetchDetail(created.id, includeSteps: true); return fetchDetail(created.id, includeSteps: true);
} }
/// 템플릿 기본 정보와 단계 구성을 수정한다.
@override @override
Future<ApprovalTemplate> update( Future<ApprovalTemplate> update(
int id, int id,
@@ -87,11 +95,13 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
return fetchDetail(id, includeSteps: true); return fetchDetail(id, includeSteps: true);
} }
/// 템플릿을 삭제한다.
@override @override
Future<void> delete(int id) async { Future<void> delete(int id) async {
await _api.delete<void>('$_basePath/$id'); await _api.delete<void>('$_basePath/$id');
} }
/// 삭제된 템플릿을 복구한다.
@override @override
Future<ApprovalTemplate> restore(int id) async { Future<ApprovalTemplate> restore(int id) async {
final response = await _api.post<Map<String, dynamic>>( final response = await _api.post<Map<String, dynamic>>(
@@ -102,6 +112,7 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
return ApprovalTemplateDto.fromJson(data).toEntity(includeSteps: false); return ApprovalTemplateDto.fromJson(data).toEntity(includeSteps: false);
} }
/// 템플릿 단계 전체를 신규로 등록한다.
Future<void> _postSteps( Future<void> _postSteps(
int templateId, int templateId,
List<ApprovalTemplateStepInput> steps, List<ApprovalTemplateStepInput> steps,
@@ -117,6 +128,7 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
); );
} }
/// 템플릿 단계 정보를 부분 수정한다.
Future<void> _patchSteps( Future<void> _patchSteps(
int templateId, int templateId,
List<ApprovalTemplateStepInput> steps, List<ApprovalTemplateStepInput> steps,

View File

@@ -2,7 +2,11 @@ import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../entities/approval.dart'; import '../entities/approval.dart';
/// 결재 도메인에서 사용하는 저장소 인터페이스.
///
/// - presentation 레이어는 이 인터페이스만 의존하며, 실제 구현은 data 레이어가 담당한다.
abstract class ApprovalRepository { abstract class ApprovalRepository {
/// 결재 목록을 조회한다. 필터/페이지 조건을 지원한다.
Future<PaginatedResult<Approval>> list({ Future<PaginatedResult<Approval>> list({
int page = 1, int page = 1,
int pageSize = 20, int pageSize = 20,
@@ -14,6 +18,7 @@ abstract class ApprovalRepository {
bool includeSteps = false, bool includeSteps = false,
}); });
/// 결재 상세 정보를 조회한다.
Future<Approval> fetchDetail( Future<Approval> fetchDetail(
int id, { int id, {
bool includeSteps = true, bool includeSteps = true,
@@ -29,11 +34,15 @@ abstract class ApprovalRepository {
/// 결재 단계 일괄 생성/재배치 /// 결재 단계 일괄 생성/재배치
Future<Approval> assignSteps(ApprovalStepAssignmentInput input); Future<Approval> assignSteps(ApprovalStepAssignmentInput input);
/// 결재를 생성한다.
Future<Approval> create(ApprovalInput input); Future<Approval> create(ApprovalInput input);
/// 결재를 수정한다.
Future<Approval> update(int id, ApprovalInput input); Future<Approval> update(int id, ApprovalInput input);
/// 결재를 삭제한다.
Future<void> delete(int id); Future<void> delete(int id);
/// 삭제된 결재를 복구한다.
Future<Approval> restore(int id); Future<Approval> restore(int id);
} }

View File

@@ -2,7 +2,9 @@ import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../entities/approval_template.dart'; import '../entities/approval_template.dart';
/// 결재 템플릿 도메인 저장소 인터페이스.
abstract class ApprovalTemplateRepository { abstract class ApprovalTemplateRepository {
/// 템플릿 목록을 조회한다.
Future<PaginatedResult<ApprovalTemplate>> list({ Future<PaginatedResult<ApprovalTemplate>> list({
int page = 1, int page = 1,
int pageSize = 20, int pageSize = 20,
@@ -10,20 +12,25 @@ abstract class ApprovalTemplateRepository {
bool? isActive, bool? isActive,
}); });
/// 템플릿 상세를 조회한다.
Future<ApprovalTemplate> fetchDetail(int id, {bool includeSteps = true}); Future<ApprovalTemplate> fetchDetail(int id, {bool includeSteps = true});
/// 템플릿을 생성한다. 단계 입력은 옵션이다.
Future<ApprovalTemplate> create( Future<ApprovalTemplate> create(
ApprovalTemplateInput input, { ApprovalTemplateInput input, {
List<ApprovalTemplateStepInput> steps = const [], List<ApprovalTemplateStepInput> steps = const [],
}); });
/// 템플릿 기본 정보와 단계 구성을 수정한다.
Future<ApprovalTemplate> update( Future<ApprovalTemplate> update(
int id, int id,
ApprovalTemplateInput input, { ApprovalTemplateInput input, {
List<ApprovalTemplateStepInput>? steps, List<ApprovalTemplateStepInput>? steps,
}); });
/// 템플릿을 삭제한다.
Future<void> delete(int id); Future<void> delete(int id);
/// 삭제된 템플릿을 복구한다.
Future<ApprovalTemplate> restore(int id); Future<ApprovalTemplate> restore(int id);
} }

View File

@@ -5,6 +5,7 @@ import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
import '../../domain/entities/approval_history_record.dart'; import '../../domain/entities/approval_history_record.dart';
/// 결재 이력(History) API 응답을 표현하는 DTO.
class ApprovalHistoryRecordDto { class ApprovalHistoryRecordDto {
ApprovalHistoryRecordDto({ ApprovalHistoryRecordDto({
required this.id, required this.id,
@@ -30,6 +31,7 @@ class ApprovalHistoryRecordDto {
final DateTime actionAt; final DateTime actionAt;
final String? note; final String? note;
/// 원본 JSON에서 필드를 파싱해 DTO를 생성한다.
factory ApprovalHistoryRecordDto.fromJson(Map<String, dynamic> json) { factory ApprovalHistoryRecordDto.fromJson(Map<String, dynamic> json) {
final approvalData = json['approval'] as Map<String, dynamic>?; final approvalData = json['approval'] as Map<String, dynamic>?;
final id = json['id'] as int? ?? 0; final id = json['id'] as int? ?? 0;
@@ -74,6 +76,7 @@ class ApprovalHistoryRecordDto {
); );
} }
/// DTO를 도메인 [ApprovalHistoryRecord] 엔티티로 변환한다.
ApprovalHistoryRecord toEntity() { ApprovalHistoryRecord toEntity() {
return ApprovalHistoryRecord( return ApprovalHistoryRecord(
id: id, id: id,
@@ -89,6 +92,7 @@ class ApprovalHistoryRecordDto {
); );
} }
/// 페이징 응답을 읽어 [PaginatedResult] 형태로 변환한다.
static PaginatedResult<ApprovalHistoryRecord> parsePaginated( static PaginatedResult<ApprovalHistoryRecord> parsePaginated(
Map<String, dynamic>? json, Map<String, dynamic>? json,
) { ) {
@@ -107,6 +111,7 @@ class ApprovalHistoryRecordDto {
} }
} }
/// 다양한 형식으로 전달될 수 있는 날짜 값을 파싱한다.
DateTime? _parseDate(Object? value) { DateTime? _parseDate(Object? value) {
if (value == null) return null; if (value == null) return null;
if (value is DateTime) return value; if (value is DateTime) return value;

View File

@@ -6,6 +6,7 @@ import '../../domain/entities/approval_history_record.dart';
import '../../domain/repositories/approval_history_repository.dart'; import '../../domain/repositories/approval_history_repository.dart';
import '../dtos/approval_history_record_dto.dart'; import '../dtos/approval_history_record_dto.dart';
/// 결재 이력 API를 호출하는 원격 저장소 구현체.
class ApprovalHistoryRepositoryRemote implements ApprovalHistoryRepository { class ApprovalHistoryRepositoryRemote implements ApprovalHistoryRepository {
ApprovalHistoryRepositoryRemote({required ApiClient apiClient}) ApprovalHistoryRepositoryRemote({required ApiClient apiClient})
: _api = apiClient; : _api = apiClient;
@@ -14,6 +15,7 @@ class ApprovalHistoryRepositoryRemote implements ApprovalHistoryRepository {
static const _basePath = '/approval-histories'; static const _basePath = '/approval-histories';
/// 결재 이력 목록을 조회한다.
@override @override
Future<PaginatedResult<ApprovalHistoryRecord>> list({ Future<PaginatedResult<ApprovalHistoryRecord>> list({
int page = 1, int page = 1,

View File

@@ -2,7 +2,9 @@ import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../entities/approval_history_record.dart'; import '../entities/approval_history_record.dart';
/// 결재 이력 데이터를 조회하는 도메인 저장소 인터페이스.
abstract class ApprovalHistoryRepository { abstract class ApprovalHistoryRepository {
/// 이력 목록을 조회한다.
Future<PaginatedResult<ApprovalHistoryRecord>> list({ Future<PaginatedResult<ApprovalHistoryRecord>> list({
int page = 1, int page = 1,
int pageSize = 20, int pageSize = 20,

View File

@@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart';
import '../../../presentation/pages/approval_page.dart'; import '../../../presentation/pages/approval_page.dart';
/// 결재 요청 탭에서 사용하는 래퍼 페이지. 실 구현은 [ApprovalPage]를 재사용한다.
class ApprovalRequestPage extends StatelessWidget { class ApprovalRequestPage extends StatelessWidget {
const ApprovalRequestPage({super.key}); const ApprovalRequestPage({super.key});

View File

@@ -5,6 +5,7 @@ import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
import '../../domain/entities/approval_step_record.dart'; import '../../domain/entities/approval_step_record.dart';
/// 결재 단계 기록 API 응답을 표현하는 DTO.
class ApprovalStepRecordDto { class ApprovalStepRecordDto {
ApprovalStepRecordDto({ ApprovalStepRecordDto({
required this.approvalId, required this.approvalId,
@@ -20,6 +21,7 @@ class ApprovalStepRecordDto {
final String? templateName; final String? templateName;
final ApprovalStep step; final ApprovalStep step;
/// JSON에서 필요한 필드를 추출해 DTO를 생성한다.
factory ApprovalStepRecordDto.fromJson(Map<String, dynamic> json) { factory ApprovalStepRecordDto.fromJson(Map<String, dynamic> json) {
final approvalData = json['approval'] as Map<String, dynamic>?; final approvalData = json['approval'] as Map<String, dynamic>?;
final approvalId = final approvalId =
@@ -49,6 +51,7 @@ class ApprovalStepRecordDto {
); );
} }
/// DTO를 [ApprovalStepRecord] 엔티티로 변환한다.
ApprovalStepRecord toEntity() { ApprovalStepRecord toEntity() {
return ApprovalStepRecord( return ApprovalStepRecord(
approvalId: approvalId, approvalId: approvalId,
@@ -59,6 +62,7 @@ class ApprovalStepRecordDto {
); );
} }
/// 페이징 응답을 [PaginatedResult] 형태로 반환한다.
static PaginatedResult<ApprovalStepRecord> parsePaginated( static PaginatedResult<ApprovalStepRecord> parsePaginated(
Map<String, dynamic>? json, Map<String, dynamic>? json,
) { ) {

View File

@@ -7,6 +7,7 @@ import 'package:superport_v2/features/approvals/step/domain/repositories/approva
import '../dtos/approval_step_record_dto.dart'; import '../dtos/approval_step_record_dto.dart';
import '../../domain/entities/approval_step_input.dart'; import '../../domain/entities/approval_step_input.dart';
/// 결재 단계 API를 호출하는 원격 저장소 구현체.
class ApprovalStepRepositoryRemote implements ApprovalStepRepository { class ApprovalStepRepositoryRemote implements ApprovalStepRepository {
ApprovalStepRepositoryRemote({required ApiClient apiClient}) ApprovalStepRepositoryRemote({required ApiClient apiClient})
: _api = apiClient; : _api = apiClient;
@@ -15,6 +16,7 @@ class ApprovalStepRepositoryRemote implements ApprovalStepRepository {
static const _basePath = '/approval-steps'; static const _basePath = '/approval-steps';
/// 결재 단계 목록을 조회한다.
@override @override
Future<PaginatedResult<ApprovalStepRecord>> list({ Future<PaginatedResult<ApprovalStepRecord>> list({
int page = 1, int page = 1,
@@ -40,6 +42,7 @@ class ApprovalStepRepositoryRemote implements ApprovalStepRepository {
return ApprovalStepRecordDto.parsePaginated(response.data); return ApprovalStepRecordDto.parsePaginated(response.data);
} }
/// 단일 결재 단계 상세를 조회한다.
@override @override
Future<ApprovalStepRecord> fetchDetail(int id) async { Future<ApprovalStepRecord> fetchDetail(int id) async {
final response = await _api.get<Map<String, dynamic>>( final response = await _api.get<Map<String, dynamic>>(
@@ -50,6 +53,7 @@ class ApprovalStepRepositoryRemote implements ApprovalStepRepository {
return ApprovalStepRecordDto.fromJson(data).toEntity(); return ApprovalStepRecordDto.fromJson(data).toEntity();
} }
/// 결재 단계를 생성한다.
@override @override
Future<ApprovalStepRecord> create(ApprovalStepInput input) async { Future<ApprovalStepRecord> create(ApprovalStepInput input) async {
final response = await _api.post<Map<String, dynamic>>( final response = await _api.post<Map<String, dynamic>>(
@@ -64,6 +68,7 @@ class ApprovalStepRepositoryRemote implements ApprovalStepRepository {
return ApprovalStepRecordDto.fromJson(data).toEntity(); return ApprovalStepRecordDto.fromJson(data).toEntity();
} }
/// 결재 단계를 수정한다.
@override @override
Future<ApprovalStepRecord> update(int id, ApprovalStepInput input) async { Future<ApprovalStepRecord> update(int id, ApprovalStepInput input) async {
final response = await _api.patch<Map<String, dynamic>>( final response = await _api.patch<Map<String, dynamic>>(

View File

@@ -3,7 +3,9 @@ import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../entities/approval_step_input.dart'; import '../entities/approval_step_input.dart';
import '../entities/approval_step_record.dart'; import '../entities/approval_step_record.dart';
/// 결재 단계 목록/상세를 다루는 도메인 저장소 인터페이스.
abstract class ApprovalStepRepository { abstract class ApprovalStepRepository {
/// 결재 단계 목록을 조회한다.
Future<PaginatedResult<ApprovalStepRecord>> list({ Future<PaginatedResult<ApprovalStepRecord>> list({
int page = 1, int page = 1,
int pageSize = 20, int pageSize = 20,
@@ -13,6 +15,7 @@ abstract class ApprovalStepRepository {
int? approvalId, int? approvalId,
}); });
/// 결재 단계 상세를 조회한다.
Future<ApprovalStepRecord> fetchDetail(int id); Future<ApprovalStepRecord> fetchDetail(int id);
/// 결재 단계를 생성한다. /// 결재 단계를 생성한다.

View File

@@ -14,6 +14,7 @@ import '../../domain/entities/approval_step_input.dart';
import '../../domain/entities/approval_step_record.dart'; import '../../domain/entities/approval_step_record.dart';
import '../../domain/repositories/approval_step_repository.dart'; import '../../domain/repositories/approval_step_repository.dart';
/// 결재 단계 관리 진입 페이지. 기능 플래그에 따라 실제 화면 또는 준비중 화면을 노출한다.
class ApprovalStepPage extends StatelessWidget { class ApprovalStepPage extends StatelessWidget {
const ApprovalStepPage({super.key}); const ApprovalStepPage({super.key});
@@ -50,6 +51,7 @@ class ApprovalStepPage extends StatelessWidget {
} }
} }
/// 결재 단계 기능이 활성화된 경우 사용하는 실제 화면 위젯.
class _ApprovalStepEnabledPage extends StatefulWidget { class _ApprovalStepEnabledPage extends StatefulWidget {
const _ApprovalStepEnabledPage(); const _ApprovalStepEnabledPage();
@@ -58,6 +60,7 @@ class _ApprovalStepEnabledPage extends StatefulWidget {
_ApprovalStepEnabledPageState(); _ApprovalStepEnabledPageState();
} }
/// 결재 단계 목록과 필터 상태를 관리하는 상태 클래스.
class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
late final ApprovalStepController _controller; late final ApprovalStepController _controller;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();

View File

@@ -15,6 +15,7 @@ import '../../../domain/entities/approval_template.dart';
import '../../../domain/repositories/approval_template_repository.dart'; import '../../../domain/repositories/approval_template_repository.dart';
import '../controllers/approval_template_controller.dart'; import '../controllers/approval_template_controller.dart';
/// 결재 템플릿 관리 페이지. 기능 플래그에 따라 준비중 화면을 노출한다.
class ApprovalTemplatePage extends StatelessWidget { class ApprovalTemplatePage extends StatelessWidget {
const ApprovalTemplatePage({super.key}); const ApprovalTemplatePage({super.key});
@@ -51,6 +52,7 @@ class ApprovalTemplatePage extends StatelessWidget {
} }
} }
/// 결재 템플릿 기능이 활성화된 경우 사용하는 실제 화면 위젯.
class _ApprovalTemplateEnabledPage extends StatefulWidget { class _ApprovalTemplateEnabledPage extends StatefulWidget {
const _ApprovalTemplateEnabledPage(); const _ApprovalTemplateEnabledPage();
@@ -59,6 +61,7 @@ class _ApprovalTemplateEnabledPage extends StatefulWidget {
_ApprovalTemplateEnabledPageState(); _ApprovalTemplateEnabledPageState();
} }
/// 템플릿 목록/필터/폼 상태를 관리하는 상태 클래스.
class _ApprovalTemplateEnabledPageState class _ApprovalTemplateEnabledPageState
extends State<_ApprovalTemplateEnabledPage> { extends State<_ApprovalTemplateEnabledPage> {
late final ApprovalTemplateController _controller; late final ApprovalTemplateController _controller;

View File

@@ -5,6 +5,7 @@ import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/app_layout.dart';
import 'package:superport_v2/widgets/components/empty_state.dart'; import 'package:superport_v2/widgets/components/empty_state.dart';
/// Superport 메인 대시보드 화면.
class DashboardPage extends StatelessWidget { class DashboardPage extends StatelessWidget {
const DashboardPage({super.key}); const DashboardPage({super.key});

View File

@@ -4,6 +4,7 @@ import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../core/constants/app_sections.dart'; import '../../../../core/constants/app_sections.dart';
/// Superport 로그인 화면. 간단한 유효성 검증 후 대시보드로 이동한다.
class LoginPage extends StatefulWidget { class LoginPage extends StatefulWidget {
const LoginPage({super.key}); const LoginPage({super.key});
@@ -11,6 +12,7 @@ class LoginPage extends StatefulWidget {
State<LoginPage> createState() => _LoginPageState(); State<LoginPage> createState() => _LoginPageState();
} }
/// 로그인 폼의 상태를 관리한다.
class _LoginPageState extends State<LoginPage> { class _LoginPageState extends State<LoginPage> {
final idController = TextEditingController(); final idController = TextEditingController();
final passwordController = TextEditingController(); final passwordController = TextEditingController();

View File

@@ -3,6 +3,7 @@ import 'package:superport_v2/core/common/utils/json_utils.dart';
import '../../domain/entities/customer.dart'; import '../../domain/entities/customer.dart';
/// 고객(Customer) API 응답을 다루는 DTO.
class CustomerDto { class CustomerDto {
CustomerDto({ CustomerDto({
this.id, this.id,
@@ -36,6 +37,7 @@ class CustomerDto {
final DateTime? createdAt; final DateTime? createdAt;
final DateTime? updatedAt; final DateTime? updatedAt;
/// 원본 JSON으로부터 DTO를 생성한다.
factory CustomerDto.fromJson(Map<String, dynamic> json) { factory CustomerDto.fromJson(Map<String, dynamic> json) {
return CustomerDto( return CustomerDto(
id: json['id'] as int?, id: json['id'] as int?,
@@ -57,6 +59,7 @@ class CustomerDto {
); );
} }
/// DTO를 JSON 맵으로 변환한다.
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
if (id != null) 'id': id, if (id != null) 'id': id,
@@ -76,6 +79,7 @@ class CustomerDto {
}; };
} }
/// DTO를 도메인 [Customer] 엔티티로 변환한다.
Customer toEntity() => Customer( Customer toEntity() => Customer(
id: id, id: id,
customerCode: customerCode, customerCode: customerCode,
@@ -93,6 +97,7 @@ class CustomerDto {
updatedAt: updatedAt, updatedAt: updatedAt,
); );
/// 페이징 응답을 파싱해 [PaginatedResult] 형식으로 반환한다.
static PaginatedResult<Customer> parsePaginated(Map<String, dynamic>? json) { static PaginatedResult<Customer> parsePaginated(Map<String, dynamic>? json) {
final rawItems = JsonUtils.extractList(json, keys: const ['items']); final rawItems = JsonUtils.extractList(json, keys: const ['items']);
final items = rawItems final items = rawItems
@@ -108,6 +113,7 @@ class CustomerDto {
} }
} }
/// 고객 주소의 우편번호 정보를 담는 DTO.
class CustomerZipcodeDto { class CustomerZipcodeDto {
CustomerZipcodeDto({ CustomerZipcodeDto({
required this.zipcode, required this.zipcode,
@@ -121,6 +127,7 @@ class CustomerZipcodeDto {
final String? sigungu; final String? sigungu;
final String? roadName; final String? roadName;
/// JSON에서 우편번호 정보를 파싱한다.
factory CustomerZipcodeDto.fromJson(Map<String, dynamic> json) { factory CustomerZipcodeDto.fromJson(Map<String, dynamic> json) {
return CustomerZipcodeDto( return CustomerZipcodeDto(
zipcode: json['zipcode'] as String, zipcode: json['zipcode'] as String,
@@ -130,6 +137,7 @@ class CustomerZipcodeDto {
); );
} }
/// DTO를 JSON 맵으로 직렬화한다.
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
'zipcode': zipcode, 'zipcode': zipcode,
@@ -139,6 +147,7 @@ class CustomerZipcodeDto {
}; };
} }
/// DTO를 [CustomerZipcode] 엔티티로 변환한다.
CustomerZipcode toEntity() => CustomerZipcode( CustomerZipcode toEntity() => CustomerZipcode(
zipcode: zipcode, zipcode: zipcode,
sido: sido, sido: sido,
@@ -147,6 +156,7 @@ class CustomerZipcodeDto {
); );
} }
/// 문자열/DateTime 값을 파싱해 [DateTime]으로 변환한다.
DateTime? _parseDate(Object? value) { DateTime? _parseDate(Object? value) {
if (value == null) return null; if (value == null) return null;
if (value is DateTime) return value; if (value is DateTime) return value;
@@ -154,6 +164,7 @@ DateTime? _parseDate(Object? value) {
return null; return null;
} }
/// 고객 입력 모델을 API 요청 바디로 변환한다.
Map<String, dynamic> customerInputToJson(CustomerInput input) { Map<String, dynamic> customerInputToJson(CustomerInput input) {
final map = input.toPayload(); final map = input.toPayload();
map.removeWhere((key, value) => value == null); map.removeWhere((key, value) => value == null);

View File

@@ -6,6 +6,7 @@ import '../../domain/entities/customer.dart';
import '../../domain/repositories/customer_repository.dart'; import '../../domain/repositories/customer_repository.dart';
import '../dtos/customer_dto.dart'; import '../dtos/customer_dto.dart';
/// 고객(API) CRUD를 호출하는 원격 저장소 구현체.
class CustomerRepositoryRemote implements CustomerRepository { class CustomerRepositoryRemote implements CustomerRepository {
CustomerRepositoryRemote({required ApiClient apiClient}) : _api = apiClient; CustomerRepositoryRemote({required ApiClient apiClient}) : _api = apiClient;
@@ -13,6 +14,7 @@ class CustomerRepositoryRemote implements CustomerRepository {
static const _basePath = '/customers'; static const _basePath = '/customers';
/// 고객 목록을 조회한다.
@override @override
Future<PaginatedResult<Customer>> list({ Future<PaginatedResult<Customer>> list({
int page = 1, int page = 1,
@@ -37,6 +39,7 @@ class CustomerRepositoryRemote implements CustomerRepository {
return CustomerDto.parsePaginated(response.data ?? const {}); return CustomerDto.parsePaginated(response.data ?? const {});
} }
/// 고객을 생성한다.
@override @override
Future<Customer> create(CustomerInput input) async { Future<Customer> create(CustomerInput input) async {
final response = await _api.post<Map<String, dynamic>>( final response = await _api.post<Map<String, dynamic>>(
@@ -48,6 +51,7 @@ class CustomerRepositoryRemote implements CustomerRepository {
return CustomerDto.fromJson(data).toEntity(); return CustomerDto.fromJson(data).toEntity();
} }
/// 고객 정보를 수정한다.
@override @override
Future<Customer> update(int id, CustomerInput input) async { Future<Customer> update(int id, CustomerInput input) async {
final response = await _api.patch<Map<String, dynamic>>( final response = await _api.patch<Map<String, dynamic>>(
@@ -59,11 +63,13 @@ class CustomerRepositoryRemote implements CustomerRepository {
return CustomerDto.fromJson(data).toEntity(); return CustomerDto.fromJson(data).toEntity();
} }
/// 고객을 삭제한다.
@override @override
Future<void> delete(int id) async { Future<void> delete(int id) async {
await _api.delete<void>('$_basePath/$id'); await _api.delete<void>('$_basePath/$id');
} }
/// 삭제된 고객을 복구한다.
@override @override
Future<Customer> restore(int id) async { Future<Customer> restore(int id) async {
final response = await _api.post<Map<String, dynamic>>( final response = await _api.post<Map<String, dynamic>>(

View File

@@ -1,3 +1,4 @@
/// 고객(Customer) 도메인 엔티티.
class Customer { class Customer {
Customer({ Customer({
this.id, this.id,
@@ -31,6 +32,7 @@ class Customer {
final DateTime? createdAt; final DateTime? createdAt;
final DateTime? updatedAt; final DateTime? updatedAt;
/// 선택한 속성만 변경한 새 인스턴스를 반환한다.
Customer copyWith({ Customer copyWith({
int? id, int? id,
String? customerCode, String? customerCode,
@@ -66,6 +68,7 @@ class Customer {
} }
} }
/// 고객 주소의 우편번호/행정구역 정보를 표현한다.
class CustomerZipcode { class CustomerZipcode {
CustomerZipcode({ CustomerZipcode({
required this.zipcode, required this.zipcode,
@@ -80,6 +83,7 @@ class CustomerZipcode {
final String? roadName; final String? roadName;
} }
/// 고객 생성/수정 시 사용하는 입력 모델.
class CustomerInput { class CustomerInput {
CustomerInput({ CustomerInput({
required this.customerCode, required this.customerCode,
@@ -105,6 +109,7 @@ class CustomerInput {
final bool isActive; final bool isActive;
final String? note; final String? note;
/// API 요청 바디에 사용하기 위한 맵으로 직렬화한다.
Map<String, dynamic> toPayload() { Map<String, dynamic> toPayload() {
return { return {
'customer_code': customerCode, 'customer_code': customerCode,

View File

@@ -2,7 +2,9 @@ import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../entities/customer.dart'; import '../entities/customer.dart';
/// 고객 데이터를 다루는 도메인 저장소 인터페이스.
abstract class CustomerRepository { abstract class CustomerRepository {
/// 고객 목록을 조회한다.
Future<PaginatedResult<Customer>> list({ Future<PaginatedResult<Customer>> list({
int page = 1, int page = 1,
int pageSize = 20, int pageSize = 20,
@@ -12,11 +14,15 @@ abstract class CustomerRepository {
bool? isActive, bool? isActive,
}); });
/// 고객을 생성한다.
Future<Customer> create(CustomerInput input); Future<Customer> create(CustomerInput input);
/// 고객을 수정한다.
Future<Customer> update(int id, CustomerInput input); Future<Customer> update(int id, CustomerInput input);
/// 고객을 삭제한다.
Future<void> delete(int id); Future<void> delete(int id);
/// 삭제된 고객을 복구한다.
Future<Customer> restore(int id); Future<Customer> restore(int id);
} }

View File

@@ -4,10 +4,13 @@ import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../../domain/entities/customer.dart'; import '../../domain/entities/customer.dart';
import '../../domain/repositories/customer_repository.dart'; import '../../domain/repositories/customer_repository.dart';
/// 고객 유형 필터 옵션.
enum CustomerTypeFilter { all, partner, general } enum CustomerTypeFilter { all, partner, general }
/// 고객 활성 상태 필터 옵션.
enum CustomerStatusFilter { all, activeOnly, inactiveOnly } enum CustomerStatusFilter { all, activeOnly, inactiveOnly }
/// 고객 목록 조회/등록/수정을 담당하는 프레젠테이션 컨트롤러.
class CustomerController extends ChangeNotifier { class CustomerController extends ChangeNotifier {
static const int defaultPageSize = 20; static const int defaultPageSize = 20;
@@ -34,6 +37,7 @@ class CustomerController extends ChangeNotifier {
int get pageSize => _pageSize; int get pageSize => _pageSize;
String? get errorMessage => _errorMessage; String? get errorMessage => _errorMessage;
/// 고객 목록을 조회한다. 필터/페이지 상태는 내부에서 유지된다.
Future<void> fetch({int page = 1}) async { Future<void> fetch({int page = 1}) async {
_isLoading = true; _isLoading = true;
_errorMessage = null; _errorMessage = null;
@@ -82,6 +86,7 @@ class CustomerController extends ChangeNotifier {
} }
} }
/// 검색어를 변경한다.
void updateQuery(String value) { void updateQuery(String value) {
if (_query == value) { if (_query == value) {
return; return;
@@ -90,6 +95,7 @@ class CustomerController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// 고객 유형 필터를 변경한다.
void updateTypeFilter(CustomerTypeFilter filter) { void updateTypeFilter(CustomerTypeFilter filter) {
if (_typeFilter == filter) { if (_typeFilter == filter) {
return; return;
@@ -98,6 +104,7 @@ class CustomerController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// 고객 활성 상태 필터를 변경한다.
void updateStatusFilter(CustomerStatusFilter filter) { void updateStatusFilter(CustomerStatusFilter filter) {
if (_statusFilter == filter) { if (_statusFilter == filter) {
return; return;
@@ -106,6 +113,7 @@ class CustomerController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// 페이지 크기를 변경한다.
void updatePageSize(int size) { void updatePageSize(int size) {
if (size <= 0 || _pageSize == size) { if (size <= 0 || _pageSize == size) {
return; return;
@@ -114,6 +122,7 @@ class CustomerController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// 신규 고객을 생성한다.
Future<Customer?> create(CustomerInput input) async { Future<Customer?> create(CustomerInput input) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -129,6 +138,7 @@ class CustomerController extends ChangeNotifier {
} }
} }
/// 기존 고객을 수정한다.
Future<Customer?> update(int id, CustomerInput input) async { Future<Customer?> update(int id, CustomerInput input) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -144,6 +154,7 @@ class CustomerController extends ChangeNotifier {
} }
} }
/// 고객을 삭제한다.
Future<bool> delete(int id) async { Future<bool> delete(int id) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -159,6 +170,7 @@ class CustomerController extends ChangeNotifier {
} }
} }
/// 삭제된 고객을 복구한다.
Future<Customer?> restore(int id) async { Future<Customer?> restore(int id) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -174,6 +186,7 @@ class CustomerController extends ChangeNotifier {
} }
} }
/// 에러 메시지를 초기화한다.
void clearError() { void clearError() {
_errorMessage = null; _errorMessage = null;
notifyListeners(); notifyListeners();

View File

@@ -16,6 +16,7 @@ import '../../domain/entities/customer.dart';
import '../../domain/repositories/customer_repository.dart'; import '../../domain/repositories/customer_repository.dart';
import '../controllers/customer_controller.dart'; import '../controllers/customer_controller.dart';
/// 고객 관리 화면. 기능 플래그에 따라 사양 페이지를 보여주거나 실제 목록을 노출한다.
class CustomerPage extends StatelessWidget { class CustomerPage extends StatelessWidget {
const CustomerPage({super.key, required this.routeUri}); const CustomerPage({super.key, required this.routeUri});
@@ -86,6 +87,7 @@ class CustomerPage extends StatelessWidget {
} }
} }
/// 고객 관리 기능이 활성화된 경우 사용하는 실제 화면 위젯.
class _CustomerEnabledPage extends StatefulWidget { class _CustomerEnabledPage extends StatefulWidget {
const _CustomerEnabledPage({required this.routeUri}); const _CustomerEnabledPage({required this.routeUri});
@@ -95,6 +97,7 @@ class _CustomerEnabledPage extends StatefulWidget {
State<_CustomerEnabledPage> createState() => _CustomerEnabledPageState(); State<_CustomerEnabledPage> createState() => _CustomerEnabledPageState();
} }
/// 고객 목록 UI와 라우트 파라미터 싱크를 담당하는 상태 클래스.
class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
late final CustomerController _controller; late final CustomerController _controller;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
@@ -403,6 +406,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
GoRouter.of(context).go(newLocation); GoRouter.of(context).go(newLocation);
} }
/// URL 파라미터에서 고객 유형 필터 값을 파싱한다.
CustomerTypeFilter _typeFromParam(String? value) { CustomerTypeFilter _typeFromParam(String? value) {
switch (value) { switch (value) {
case 'partner': case 'partner':
@@ -414,6 +418,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
} }
} }
/// 고객 유형 필터를 URL 파라미터 문자열로 변환한다.
String? _encodeType(CustomerTypeFilter filter) { String? _encodeType(CustomerTypeFilter filter) {
switch (filter) { switch (filter) {
case CustomerTypeFilter.all: case CustomerTypeFilter.all:
@@ -425,6 +430,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
} }
} }
/// URL 파라미터에서 고객 활성 상태를 파싱한다.
CustomerStatusFilter _statusFromParam(String? value) { CustomerStatusFilter _statusFromParam(String? value) {
switch (value) { switch (value) {
case 'active': case 'active':
@@ -436,6 +442,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
} }
} }
/// 고객 상태 필터를 URL 파라미터 문자열로 변환한다.
String? _encodeStatus(CustomerStatusFilter filter) { String? _encodeStatus(CustomerStatusFilter filter) {
switch (filter) { switch (filter) {
case CustomerStatusFilter.all: case CustomerStatusFilter.all:

View File

@@ -3,6 +3,7 @@ import 'package:superport_v2/core/common/utils/json_utils.dart';
import '../../domain/entities/group.dart'; import '../../domain/entities/group.dart';
/// 권한 그룹(Group) API 응답을 표현하는 DTO.
class GroupDto { class GroupDto {
GroupDto({ GroupDto({
this.id, this.id,
@@ -26,6 +27,7 @@ class GroupDto {
final DateTime? createdAt; final DateTime? createdAt;
final DateTime? updatedAt; final DateTime? updatedAt;
/// JSON에서 그룹 정보를 파싱한다.
factory GroupDto.fromJson(Map<String, dynamic> json) { factory GroupDto.fromJson(Map<String, dynamic> json) {
return GroupDto( return GroupDto(
id: json['id'] as int?, id: json['id'] as int?,
@@ -40,6 +42,7 @@ class GroupDto {
); );
} }
/// DTO를 도메인 [Group] 엔티티로 변환한다.
Group toEntity() => Group( Group toEntity() => Group(
id: id, id: id,
groupName: groupName, groupName: groupName,
@@ -52,6 +55,7 @@ class GroupDto {
updatedAt: updatedAt, updatedAt: updatedAt,
); );
/// 페이징 응답을 [PaginatedResult]로 변환한다.
static PaginatedResult<Group> parsePaginated(Map<String, dynamic>? json) { static PaginatedResult<Group> parsePaginated(Map<String, dynamic>? json) {
final rawItems = JsonUtils.extractList(json, keys: const ['items']); final rawItems = JsonUtils.extractList(json, keys: const ['items']);
final items = rawItems final items = rawItems
@@ -67,6 +71,7 @@ class GroupDto {
} }
} }
/// 문자열/DateTime을 파싱해 [DateTime]으로 반환한다.
DateTime? _parseDate(Object? value) { DateTime? _parseDate(Object? value) {
if (value == null) return null; if (value == null) return null;
if (value is DateTime) return value; if (value is DateTime) return value;

View File

@@ -6,6 +6,7 @@ import '../../domain/entities/group.dart';
import '../../domain/repositories/group_repository.dart'; import '../../domain/repositories/group_repository.dart';
import '../dtos/group_dto.dart'; import '../dtos/group_dto.dart';
/// 권한 그룹 API를 호출하는 원격 저장소 구현체.
class GroupRepositoryRemote implements GroupRepository { class GroupRepositoryRemote implements GroupRepository {
GroupRepositoryRemote({required ApiClient apiClient}) : _api = apiClient; GroupRepositoryRemote({required ApiClient apiClient}) : _api = apiClient;
@@ -13,6 +14,7 @@ class GroupRepositoryRemote implements GroupRepository {
static const _basePath = '/groups'; static const _basePath = '/groups';
/// 그룹 목록을 조회한다.
@override @override
Future<PaginatedResult<Group>> list({ Future<PaginatedResult<Group>> list({
int page = 1, int page = 1,
@@ -35,6 +37,7 @@ class GroupRepositoryRemote implements GroupRepository {
return GroupDto.parsePaginated(response.data ?? const {}); return GroupDto.parsePaginated(response.data ?? const {});
} }
/// 새 그룹을 생성한다.
@override @override
Future<Group> create(GroupInput input) async { Future<Group> create(GroupInput input) async {
final response = await _api.post<Map<String, dynamic>>( final response = await _api.post<Map<String, dynamic>>(
@@ -46,6 +49,7 @@ class GroupRepositoryRemote implements GroupRepository {
return GroupDto.fromJson(data).toEntity(); return GroupDto.fromJson(data).toEntity();
} }
/// 그룹 정보를 수정한다.
@override @override
Future<Group> update(int id, GroupInput input) async { Future<Group> update(int id, GroupInput input) async {
final response = await _api.patch<Map<String, dynamic>>( final response = await _api.patch<Map<String, dynamic>>(
@@ -57,11 +61,13 @@ class GroupRepositoryRemote implements GroupRepository {
return GroupDto.fromJson(data).toEntity(); return GroupDto.fromJson(data).toEntity();
} }
/// 그룹을 삭제한다.
@override @override
Future<void> delete(int id) async { Future<void> delete(int id) async {
await _api.delete<void>('$_basePath/$id'); await _api.delete<void>('$_basePath/$id');
} }
/// 삭제된 그룹을 복구한다.
@override @override
Future<Group> restore(int id) async { Future<Group> restore(int id) async {
final response = await _api.post<Map<String, dynamic>>( final response = await _api.post<Map<String, dynamic>>(

View File

@@ -4,8 +4,10 @@ import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../../domain/entities/group.dart'; import '../../domain/entities/group.dart';
import '../../domain/repositories/group_repository.dart'; import '../../domain/repositories/group_repository.dart';
/// 기본 그룹 여부 필터.
enum GroupDefaultFilter { all, defaultOnly, nonDefault } enum GroupDefaultFilter { all, defaultOnly, nonDefault }
/// 그룹 사용 상태 필터.
enum GroupStatusFilter { all, activeOnly, inactiveOnly } enum GroupStatusFilter { all, activeOnly, inactiveOnly }
/// 그룹 마스터 화면 상태 컨트롤러 /// 그룹 마스터 화면 상태 컨트롤러
@@ -34,6 +36,7 @@ class GroupController extends ChangeNotifier {
GroupStatusFilter get statusFilter => _statusFilter; GroupStatusFilter get statusFilter => _statusFilter;
String? get errorMessage => _errorMessage; String? get errorMessage => _errorMessage;
/// 그룹 목록을 조회한다.
Future<void> fetch({int page = 1}) async { Future<void> fetch({int page = 1}) async {
_isLoading = true; _isLoading = true;
_errorMessage = null; _errorMessage = null;
@@ -65,21 +68,25 @@ class GroupController extends ChangeNotifier {
} }
} }
/// 검색어를 변경한다.
void updateQuery(String value) { void updateQuery(String value) {
_query = value; _query = value;
notifyListeners(); notifyListeners();
} }
/// 기본 그룹 여부 필터를 변경한다.
void updateDefaultFilter(GroupDefaultFilter filter) { void updateDefaultFilter(GroupDefaultFilter filter) {
_defaultFilter = filter; _defaultFilter = filter;
notifyListeners(); notifyListeners();
} }
/// 사용 여부 필터를 변경한다.
void updateStatusFilter(GroupStatusFilter filter) { void updateStatusFilter(GroupStatusFilter filter) {
_statusFilter = filter; _statusFilter = filter;
notifyListeners(); notifyListeners();
} }
/// 새 그룹을 생성한다.
Future<Group?> create(GroupInput input) async { Future<Group?> create(GroupInput input) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -95,6 +102,7 @@ class GroupController extends ChangeNotifier {
} }
} }
/// 그룹 정보를 수정한다.
Future<Group?> update(int id, GroupInput input) async { Future<Group?> update(int id, GroupInput input) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -110,6 +118,7 @@ class GroupController extends ChangeNotifier {
} }
} }
/// 그룹을 삭제한다.
Future<bool> delete(int id) async { Future<bool> delete(int id) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -125,6 +134,7 @@ class GroupController extends ChangeNotifier {
} }
} }
/// 삭제된 그룹을 복구한다.
Future<Group?> restore(int id) async { Future<Group?> restore(int id) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -140,11 +150,13 @@ class GroupController extends ChangeNotifier {
} }
} }
/// 에러 메시지를 초기화한다.
void clearError() { void clearError() {
_errorMessage = null; _errorMessage = null;
notifyListeners(); notifyListeners();
} }
/// 제출 상태 플래그를 갱신하고 리스너에 알린다.
void _setSubmitting(bool value) { void _setSubmitting(bool value) {
_isSubmitting = value; _isSubmitting = value;
notifyListeners(); notifyListeners();

View File

@@ -13,6 +13,7 @@ import '../../domain/entities/group.dart';
import '../../domain/repositories/group_repository.dart'; import '../../domain/repositories/group_repository.dart';
import '../controllers/group_controller.dart'; import '../controllers/group_controller.dart';
/// 권한 그룹 관리 페이지. 기능 플래그에 따라 사양 화면 또는 실제 목록을 보여준다.
class GroupPage extends StatelessWidget { class GroupPage extends StatelessWidget {
const GroupPage({super.key}); const GroupPage({super.key});
@@ -69,6 +70,7 @@ class GroupPage extends StatelessWidget {
} }
} }
/// 그룹 기능이 활성화된 경우 사용하는 실제 화면 위젯.
class _GroupEnabledPage extends StatefulWidget { class _GroupEnabledPage extends StatefulWidget {
const _GroupEnabledPage(); const _GroupEnabledPage();
@@ -76,6 +78,7 @@ class _GroupEnabledPage extends StatefulWidget {
State<_GroupEnabledPage> createState() => _GroupEnabledPageState(); State<_GroupEnabledPage> createState() => _GroupEnabledPageState();
} }
/// 그룹 목록과 필터/폼 상태를 관리하는 상태 클래스.
class _GroupEnabledPageState extends State<_GroupEnabledPage> { class _GroupEnabledPageState extends State<_GroupEnabledPage> {
late final GroupController _controller; late final GroupController _controller;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();

View File

@@ -2,6 +2,7 @@ import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../../domain/entities/group_permission.dart'; import '../../domain/entities/group_permission.dart';
/// 그룹별 메뉴 권한을 표현하는 DTO.
class GroupPermissionDto { class GroupPermissionDto {
GroupPermissionDto({ GroupPermissionDto({
this.id, this.id,
@@ -31,6 +32,7 @@ class GroupPermissionDto {
final DateTime? createdAt; final DateTime? createdAt;
final DateTime? updatedAt; final DateTime? updatedAt;
/// JSON에서 권한 정보를 파싱한다.
factory GroupPermissionDto.fromJson(Map<String, dynamic> json) { factory GroupPermissionDto.fromJson(Map<String, dynamic> json) {
return GroupPermissionDto( return GroupPermissionDto(
id: json['id'] as int?, id: json['id'] as int?,
@@ -52,6 +54,7 @@ class GroupPermissionDto {
); );
} }
/// DTO를 도메인 [GroupPermission] 엔티티로 변환한다.
GroupPermission toEntity() => GroupPermission( GroupPermission toEntity() => GroupPermission(
id: id, id: id,
group: group.toEntity(), group: group.toEntity(),
@@ -67,6 +70,7 @@ class GroupPermissionDto {
updatedAt: updatedAt, updatedAt: updatedAt,
); );
/// 페이징 응답을 [PaginatedResult]로 변환한다.
static PaginatedResult<GroupPermission> parsePaginated( static PaginatedResult<GroupPermission> parsePaginated(
Map<String, dynamic>? json, Map<String, dynamic>? json,
) { ) {
@@ -84,12 +88,14 @@ class GroupPermissionDto {
} }
} }
/// 권한 설정에 포함된 그룹 정보를 담는 DTO.
class GroupPermissionGroupDto { class GroupPermissionGroupDto {
GroupPermissionGroupDto({required this.id, required this.groupName}); GroupPermissionGroupDto({required this.id, required this.groupName});
final int id; final int id;
final String groupName; final String groupName;
/// JSON에서 그룹 정보를 파싱한다.
factory GroupPermissionGroupDto.fromJson(Map<String, dynamic> json) { factory GroupPermissionGroupDto.fromJson(Map<String, dynamic> json) {
return GroupPermissionGroupDto( return GroupPermissionGroupDto(
id: json['id'] as int? ?? json['group_id'] as int, id: json['id'] as int? ?? json['group_id'] as int,
@@ -98,16 +104,19 @@ class GroupPermissionGroupDto {
); );
} }
/// DTO를 [GroupPermissionGroup] 엔티티로 변환한다.
GroupPermissionGroup toEntity() => GroupPermissionGroup toEntity() =>
GroupPermissionGroup(id: id, groupName: groupName); GroupPermissionGroup(id: id, groupName: groupName);
} }
/// 권한 대상 메뉴 정보를 담는 DTO.
class GroupPermissionMenuDto { class GroupPermissionMenuDto {
GroupPermissionMenuDto({required this.id, required this.menuName}); GroupPermissionMenuDto({required this.id, required this.menuName});
final int id; final int id;
final String menuName; final String menuName;
/// JSON에서 메뉴 정보를 파싱한다.
factory GroupPermissionMenuDto.fromJson(Map<String, dynamic> json) { factory GroupPermissionMenuDto.fromJson(Map<String, dynamic> json) {
return GroupPermissionMenuDto( return GroupPermissionMenuDto(
id: json['id'] as int? ?? json['menu_id'] as int, id: json['id'] as int? ?? json['menu_id'] as int,
@@ -115,10 +124,12 @@ class GroupPermissionMenuDto {
); );
} }
/// DTO를 [GroupPermissionMenu] 엔티티로 변환한다.
GroupPermissionMenu toEntity() => GroupPermissionMenu toEntity() =>
GroupPermissionMenu(id: id, menuName: menuName); GroupPermissionMenu(id: id, menuName: menuName);
} }
/// 문자열/DateTime 값을 파싱해 [DateTime]으로 변환한다.
DateTime? _parseDate(Object? value) { DateTime? _parseDate(Object? value) {
if (value == null) return null; if (value == null) return null;
if (value is DateTime) return value; if (value is DateTime) return value;

View File

@@ -6,6 +6,7 @@ import '../../domain/entities/group_permission.dart';
import '../../domain/repositories/group_permission_repository.dart'; import '../../domain/repositories/group_permission_repository.dart';
import '../dtos/group_permission_dto.dart'; import '../dtos/group_permission_dto.dart';
/// 그룹-메뉴 권한 API를 호출하는 원격 저장소.
class GroupPermissionRepositoryRemote implements GroupPermissionRepository { class GroupPermissionRepositoryRemote implements GroupPermissionRepository {
GroupPermissionRepositoryRemote({required ApiClient apiClient}) GroupPermissionRepositoryRemote({required ApiClient apiClient})
: _api = apiClient; : _api = apiClient;
@@ -14,6 +15,7 @@ class GroupPermissionRepositoryRemote implements GroupPermissionRepository {
static const _basePath = '/group-menu-permissions'; static const _basePath = '/group-menu-permissions';
/// 그룹 권한 목록을 조회한다.
@override @override
Future<PaginatedResult<GroupPermission>> list({ Future<PaginatedResult<GroupPermission>> list({
int page = 1, int page = 1,
@@ -39,6 +41,7 @@ class GroupPermissionRepositoryRemote implements GroupPermissionRepository {
return GroupPermissionDto.parsePaginated(response.data ?? const {}); return GroupPermissionDto.parsePaginated(response.data ?? const {});
} }
/// 그룹 권한을 생성한다.
@override @override
Future<GroupPermission> create(GroupPermissionInput input) async { Future<GroupPermission> create(GroupPermissionInput input) async {
final response = await _api.post<Map<String, dynamic>>( final response = await _api.post<Map<String, dynamic>>(
@@ -50,6 +53,7 @@ class GroupPermissionRepositoryRemote implements GroupPermissionRepository {
return GroupPermissionDto.fromJson(data).toEntity(); return GroupPermissionDto.fromJson(data).toEntity();
} }
/// 그룹 권한을 수정한다.
@override @override
Future<GroupPermission> update(int id, GroupPermissionInput input) async { Future<GroupPermission> update(int id, GroupPermissionInput input) async {
final response = await _api.patch<Map<String, dynamic>>( final response = await _api.patch<Map<String, dynamic>>(
@@ -61,11 +65,13 @@ class GroupPermissionRepositoryRemote implements GroupPermissionRepository {
return GroupPermissionDto.fromJson(data).toEntity(); return GroupPermissionDto.fromJson(data).toEntity();
} }
/// 그룹 권한을 삭제한다.
@override @override
Future<void> delete(int id) async { Future<void> delete(int id) async {
await _api.delete<void>('$_basePath/$id'); await _api.delete<void>('$_basePath/$id');
} }
/// 삭제된 그룹 권한을 복구한다.
@override @override
Future<GroupPermission> restore(int id) async { Future<GroupPermission> restore(int id) async {
final response = await _api.post<Map<String, dynamic>>( final response = await _api.post<Map<String, dynamic>>(

View File

@@ -2,7 +2,9 @@ import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../entities/group_permission.dart'; import '../entities/group_permission.dart';
/// 그룹-메뉴 권한을 다루는 도메인 저장소 인터페이스.
abstract class GroupPermissionRepository { abstract class GroupPermissionRepository {
/// 권한 목록을 조회한다.
Future<PaginatedResult<GroupPermission>> list({ Future<PaginatedResult<GroupPermission>> list({
int page = 1, int page = 1,
int pageSize = 20, int pageSize = 20,
@@ -12,11 +14,15 @@ abstract class GroupPermissionRepository {
bool includeDeleted = false, bool includeDeleted = false,
}); });
/// 그룹 권한을 생성한다.
Future<GroupPermission> create(GroupPermissionInput input); Future<GroupPermission> create(GroupPermissionInput input);
/// 그룹 권한을 수정한다.
Future<GroupPermission> update(int id, GroupPermissionInput input); Future<GroupPermission> update(int id, GroupPermissionInput input);
/// 그룹 권한을 삭제한다.
Future<void> delete(int id); Future<void> delete(int id);
/// 삭제된 그룹 권한을 복구한다.
Future<GroupPermission> restore(int id); Future<GroupPermission> restore(int id);
} }

View File

@@ -8,6 +8,7 @@ import '../../../menu/domain/repositories/menu_repository.dart';
import '../../domain/entities/group_permission.dart'; import '../../domain/entities/group_permission.dart';
import '../../domain/repositories/group_permission_repository.dart'; import '../../domain/repositories/group_permission_repository.dart';
/// 그룹 권한 활성 여부 필터.
enum GroupPermissionStatusFilter { all, activeOnly, inactiveOnly } enum GroupPermissionStatusFilter { all, activeOnly, inactiveOnly }
/// 그룹-메뉴 권한 화면용 컨트롤러 /// 그룹-메뉴 권한 화면용 컨트롤러
@@ -53,6 +54,7 @@ class GroupPermissionController extends ChangeNotifier {
List<Group> get groups => List.unmodifiable(_groups); List<Group> get groups => List.unmodifiable(_groups);
List<MenuItem> get menus => List.unmodifiable(_menus); List<MenuItem> get menus => List.unmodifiable(_menus);
/// 그룹 목록을 로드해 권한 연결 시 선택할 수 있도록 준비한다.
Future<void> loadGroups() async { Future<void> loadGroups() async {
_isLoadingGroups = true; _isLoadingGroups = true;
notifyListeners(); notifyListeners();
@@ -69,6 +71,7 @@ class GroupPermissionController extends ChangeNotifier {
} }
} }
/// 메뉴 목록을 로드해 권한 연결 시 선택할 수 있도록 준비한다.
Future<void> loadMenus() async { Future<void> loadMenus() async {
_isLoadingMenus = true; _isLoadingMenus = true;
notifyListeners(); notifyListeners();
@@ -89,6 +92,7 @@ class GroupPermissionController extends ChangeNotifier {
} }
} }
/// 그룹 권한 목록을 조회한다.
Future<void> fetch({int page = 1}) async { Future<void> fetch({int page = 1}) async {
_isLoading = true; _isLoading = true;
_errorMessage = null; _errorMessage = null;
@@ -116,26 +120,31 @@ class GroupPermissionController extends ChangeNotifier {
} }
} }
/// 그룹 필터를 변경한다.
void updateGroupFilter(int? groupId) { void updateGroupFilter(int? groupId) {
_groupFilter = groupId; _groupFilter = groupId;
notifyListeners(); notifyListeners();
} }
/// 메뉴 필터를 변경한다.
void updateMenuFilter(int? menuId) { void updateMenuFilter(int? menuId) {
_menuFilter = menuId; _menuFilter = menuId;
notifyListeners(); notifyListeners();
} }
/// 권한 활성 상태 필터를 변경한다.
void updateStatusFilter(GroupPermissionStatusFilter filter) { void updateStatusFilter(GroupPermissionStatusFilter filter) {
_statusFilter = filter; _statusFilter = filter;
notifyListeners(); notifyListeners();
} }
/// 삭제 포함 여부를 변경한다.
void updateIncludeDeleted(bool value) { void updateIncludeDeleted(bool value) {
_includeDeleted = value; _includeDeleted = value;
notifyListeners(); notifyListeners();
} }
/// 그룹 권한을 생성한다.
Future<GroupPermission?> create(GroupPermissionInput input) async { Future<GroupPermission?> create(GroupPermissionInput input) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -151,6 +160,7 @@ class GroupPermissionController extends ChangeNotifier {
} }
} }
/// 그룹 권한을 수정한다.
Future<GroupPermission?> update(int id, GroupPermissionInput input) async { Future<GroupPermission?> update(int id, GroupPermissionInput input) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -166,6 +176,7 @@ class GroupPermissionController extends ChangeNotifier {
} }
} }
/// 그룹 권한을 삭제한다.
Future<bool> delete(int id) async { Future<bool> delete(int id) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -181,6 +192,7 @@ class GroupPermissionController extends ChangeNotifier {
} }
} }
/// 삭제된 그룹 권한을 복구한다.
Future<GroupPermission?> restore(int id) async { Future<GroupPermission?> restore(int id) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -196,11 +208,13 @@ class GroupPermissionController extends ChangeNotifier {
} }
} }
/// 에러 메시지를 초기화한다.
void clearError() { void clearError() {
_errorMessage = null; _errorMessage = null;
notifyListeners(); notifyListeners();
} }
/// 제출 상태 플래그를 갱신하고 리스너에게 알린다.
void _setSubmitting(bool value) { void _setSubmitting(bool value) {
_isSubmitting = value; _isSubmitting = value;
notifyListeners(); notifyListeners();

View File

@@ -18,6 +18,7 @@ import '../../domain/entities/group_permission.dart';
import '../../domain/repositories/group_permission_repository.dart'; import '../../domain/repositories/group_permission_repository.dart';
import '../controllers/group_permission_controller.dart'; import '../controllers/group_permission_controller.dart';
/// 그룹-메뉴 권한 설정 페이지. 기능 플래그에 따라 사양/실제 화면을 전환한다.
class GroupPermissionPage extends StatelessWidget { class GroupPermissionPage extends StatelessWidget {
const GroupPermissionPage({super.key}); const GroupPermissionPage({super.key});
@@ -99,6 +100,7 @@ class GroupPermissionPage extends StatelessWidget {
} }
} }
/// 그룹 권한 기능이 활성화된 경우 사용하는 실제 화면 위젯.
class _GroupPermissionEnabledPage extends StatefulWidget { class _GroupPermissionEnabledPage extends StatefulWidget {
const _GroupPermissionEnabledPage(); const _GroupPermissionEnabledPage();
@@ -107,6 +109,7 @@ class _GroupPermissionEnabledPage extends StatefulWidget {
_GroupPermissionEnabledPageState(); _GroupPermissionEnabledPageState();
} }
/// 그룹 권한 목록/필터/폼 상태를 관리하는 상태 클래스.
class _GroupPermissionEnabledPageState class _GroupPermissionEnabledPageState
extends State<_GroupPermissionEnabledPage> { extends State<_GroupPermissionEnabledPage> {
late final GroupPermissionController _controller; late final GroupPermissionController _controller;

View File

@@ -3,6 +3,7 @@ import 'package:superport_v2/core/common/utils/json_utils.dart';
import '../../domain/entities/menu.dart'; import '../../domain/entities/menu.dart';
/// 메뉴(Menu) API 응답을 표현하는 DTO.
class MenuDto { class MenuDto {
MenuDto({ MenuDto({
this.id, this.id,
@@ -30,6 +31,7 @@ class MenuDto {
final DateTime? createdAt; final DateTime? createdAt;
final DateTime? updatedAt; final DateTime? updatedAt;
/// JSON에서 메뉴 정보를 파싱한다.
factory MenuDto.fromJson(Map<String, dynamic> json) { factory MenuDto.fromJson(Map<String, dynamic> json) {
return MenuDto( return MenuDto(
id: json['id'] as int?, id: json['id'] as int?,
@@ -50,6 +52,7 @@ class MenuDto {
); );
} }
/// DTO를 도메인 [MenuItem]으로 변환한다.
MenuItem toEntity() => MenuItem( MenuItem toEntity() => MenuItem(
id: id, id: id,
menuCode: menuCode, menuCode: menuCode,
@@ -64,6 +67,7 @@ class MenuDto {
updatedAt: updatedAt, updatedAt: updatedAt,
); );
/// 페이징 응답을 [PaginatedResult]로 변환한다.
static PaginatedResult<MenuItem> parsePaginated(Map<String, dynamic>? json) { static PaginatedResult<MenuItem> parsePaginated(Map<String, dynamic>? json) {
final rawItems = JsonUtils.extractList(json, keys: const ['items']); final rawItems = JsonUtils.extractList(json, keys: const ['items']);
final items = rawItems final items = rawItems
@@ -79,12 +83,14 @@ class MenuDto {
} }
} }
/// 하위 메뉴 요약 정보를 담는 DTO.
class MenuSummaryDto { class MenuSummaryDto {
MenuSummaryDto({required this.id, required this.menuName}); MenuSummaryDto({required this.id, required this.menuName});
final int id; final int id;
final String menuName; final String menuName;
/// JSON에서 요약 정보를 파싱한다.
factory MenuSummaryDto.fromJson(Map<String, dynamic> json) { factory MenuSummaryDto.fromJson(Map<String, dynamic> json) {
return MenuSummaryDto( return MenuSummaryDto(
id: json['id'] as int, id: json['id'] as int,
@@ -92,9 +98,11 @@ class MenuSummaryDto {
); );
} }
/// DTO를 [MenuSummary] 엔티티로 변환한다.
MenuSummary toEntity() => MenuSummary(id: id, menuName: menuName); MenuSummary toEntity() => MenuSummary(id: id, menuName: menuName);
} }
/// 문자열/DateTime을 파싱해 [DateTime]으로 변환한다.
DateTime? _parseDate(Object? value) { DateTime? _parseDate(Object? value) {
if (value == null) return null; if (value == null) return null;
if (value is DateTime) return value; if (value is DateTime) return value;

View File

@@ -6,6 +6,7 @@ import '../../domain/entities/menu.dart';
import '../../domain/repositories/menu_repository.dart'; import '../../domain/repositories/menu_repository.dart';
import '../dtos/menu_dto.dart'; import '../dtos/menu_dto.dart';
/// 메뉴 마스터 API를 호출하는 원격 저장소.
class MenuRepositoryRemote implements MenuRepository { class MenuRepositoryRemote implements MenuRepository {
MenuRepositoryRemote({required ApiClient apiClient}) : _api = apiClient; MenuRepositoryRemote({required ApiClient apiClient}) : _api = apiClient;
@@ -13,6 +14,7 @@ class MenuRepositoryRemote implements MenuRepository {
static const _basePath = '/menus'; static const _basePath = '/menus';
/// 메뉴 목록을 조회한다.
@override @override
Future<PaginatedResult<MenuItem>> list({ Future<PaginatedResult<MenuItem>> list({
int page = 1, int page = 1,
@@ -38,6 +40,7 @@ class MenuRepositoryRemote implements MenuRepository {
return MenuDto.parsePaginated(response.data ?? const {}); return MenuDto.parsePaginated(response.data ?? const {});
} }
/// 새 메뉴를 생성한다.
@override @override
Future<MenuItem> create(MenuInput input) async { Future<MenuItem> create(MenuInput input) async {
final response = await _api.post<Map<String, dynamic>>( final response = await _api.post<Map<String, dynamic>>(
@@ -49,6 +52,7 @@ class MenuRepositoryRemote implements MenuRepository {
return MenuDto.fromJson(data).toEntity(); return MenuDto.fromJson(data).toEntity();
} }
/// 메뉴 정보를 수정한다.
@override @override
Future<MenuItem> update(int id, MenuInput input) async { Future<MenuItem> update(int id, MenuInput input) async {
final response = await _api.patch<Map<String, dynamic>>( final response = await _api.patch<Map<String, dynamic>>(
@@ -60,11 +64,13 @@ class MenuRepositoryRemote implements MenuRepository {
return MenuDto.fromJson(data).toEntity(); return MenuDto.fromJson(data).toEntity();
} }
/// 메뉴를 삭제한다.
@override @override
Future<void> delete(int id) async { Future<void> delete(int id) async {
await _api.delete<void>('$_basePath/$id'); await _api.delete<void>('$_basePath/$id');
} }
/// 삭제된 메뉴를 복구한다.
@override @override
Future<MenuItem> restore(int id) async { Future<MenuItem> restore(int id) async {
final response = await _api.post<Map<String, dynamic>>( final response = await _api.post<Map<String, dynamic>>(

View File

@@ -4,6 +4,7 @@ import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../../domain/entities/menu.dart'; import '../../domain/entities/menu.dart';
import '../../domain/repositories/menu_repository.dart'; import '../../domain/repositories/menu_repository.dart';
/// 메뉴 사용 여부 필터.
enum MenuStatusFilter { all, activeOnly, inactiveOnly } enum MenuStatusFilter { all, activeOnly, inactiveOnly }
/// 메뉴 마스터 상태 컨트롤러 /// 메뉴 마스터 상태 컨트롤러
@@ -38,6 +39,7 @@ class MenuController extends ChangeNotifier {
String? get errorMessage => _errorMessage; String? get errorMessage => _errorMessage;
List<MenuItem> get parents => _parents; List<MenuItem> get parents => _parents;
/// 상위 메뉴 목록을 로드해 드롭다운에 표시한다.
Future<void> loadParents() async { Future<void> loadParents() async {
_isLoadingParents = true; _isLoadingParents = true;
notifyListeners(); notifyListeners();
@@ -56,6 +58,7 @@ class MenuController extends ChangeNotifier {
} }
} }
/// 메뉴 목록을 조회한다.
Future<void> fetch({int page = 1}) async { Future<void> fetch({int page = 1}) async {
_isLoading = true; _isLoading = true;
_errorMessage = null; _errorMessage = null;
@@ -83,26 +86,31 @@ class MenuController extends ChangeNotifier {
} }
} }
/// 검색어를 변경한다.
void updateQuery(String value) { void updateQuery(String value) {
_query = value; _query = value;
notifyListeners(); notifyListeners();
} }
/// 상위 메뉴 필터를 변경한다.
void updateParentFilter(int? parentId) { void updateParentFilter(int? parentId) {
_parentFilter = parentId; _parentFilter = parentId;
notifyListeners(); notifyListeners();
} }
/// 메뉴 사용 여부 필터를 변경한다.
void updateStatusFilter(MenuStatusFilter filter) { void updateStatusFilter(MenuStatusFilter filter) {
_statusFilter = filter; _statusFilter = filter;
notifyListeners(); notifyListeners();
} }
/// 삭제 포함 여부를 변경한다.
void updateIncludeDeleted(bool value) { void updateIncludeDeleted(bool value) {
_includeDeleted = value; _includeDeleted = value;
notifyListeners(); notifyListeners();
} }
/// 메뉴를 생성한다.
Future<MenuItem?> create(MenuInput input) async { Future<MenuItem?> create(MenuInput input) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -119,6 +127,7 @@ class MenuController extends ChangeNotifier {
} }
} }
/// 메뉴 정보를 수정한다.
Future<MenuItem?> update(int id, MenuInput input) async { Future<MenuItem?> update(int id, MenuInput input) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -135,6 +144,7 @@ class MenuController extends ChangeNotifier {
} }
} }
/// 메뉴를 삭제한다.
Future<bool> delete(int id) async { Future<bool> delete(int id) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -151,6 +161,7 @@ class MenuController extends ChangeNotifier {
} }
} }
/// 삭제된 메뉴를 복구한다.
Future<MenuItem?> restore(int id) async { Future<MenuItem?> restore(int id) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -167,11 +178,13 @@ class MenuController extends ChangeNotifier {
} }
} }
/// 에러 메시지를 초기화한다.
void clearError() { void clearError() {
_errorMessage = null; _errorMessage = null;
notifyListeners(); notifyListeners();
} }
/// 제출 상태 플래그를 갱신하고 리스너에 알린다.
void _setSubmitting(bool value) { void _setSubmitting(bool value) {
_isSubmitting = value; _isSubmitting = value;
notifyListeners(); notifyListeners();

View File

@@ -13,6 +13,7 @@ import '../../domain/entities/menu.dart';
import '../../domain/repositories/menu_repository.dart'; import '../../domain/repositories/menu_repository.dart';
import '../controllers/menu_controller.dart' as menu; import '../controllers/menu_controller.dart' as menu;
/// 메뉴 관리 페이지. 기능 플래그에 따라 사양/실제 화면을 전환한다.
class MenuPage extends StatelessWidget { class MenuPage extends StatelessWidget {
const MenuPage({super.key}); const MenuPage({super.key});
@@ -89,6 +90,7 @@ class MenuPage extends StatelessWidget {
} }
} }
/// 메뉴 기능이 활성화된 경우 사용하는 실제 화면 위젯.
class _MenuEnabledPage extends StatefulWidget { class _MenuEnabledPage extends StatefulWidget {
const _MenuEnabledPage(); const _MenuEnabledPage();
@@ -96,6 +98,7 @@ class _MenuEnabledPage extends StatefulWidget {
State<_MenuEnabledPage> createState() => _MenuEnabledPageState(); State<_MenuEnabledPage> createState() => _MenuEnabledPageState();
} }
/// 메뉴 목록과 필터를 관리하는 상태 클래스.
class _MenuEnabledPageState extends State<_MenuEnabledPage> { class _MenuEnabledPageState extends State<_MenuEnabledPage> {
late final menu.MenuController _controller; late final menu.MenuController _controller;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();

View File

@@ -3,6 +3,7 @@ import 'package:superport_v2/core/common/utils/json_utils.dart';
import '../../domain/entities/product.dart'; import '../../domain/entities/product.dart';
/// 제품(Product) API 응답을 표현하는 DTO.
class ProductDto { class ProductDto {
ProductDto({ ProductDto({
this.id, this.id,
@@ -28,6 +29,7 @@ class ProductDto {
final DateTime? createdAt; final DateTime? createdAt;
final DateTime? updatedAt; final DateTime? updatedAt;
/// JSON에서 제품 정보를 파싱한다.
factory ProductDto.fromJson(Map<String, dynamic> json) { factory ProductDto.fromJson(Map<String, dynamic> json) {
return ProductDto( return ProductDto(
id: json['id'] as int?, id: json['id'] as int?,
@@ -47,6 +49,7 @@ class ProductDto {
); );
} }
/// DTO를 JSON 맵으로 직렬화한다.
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
if (id != null) 'id': id, if (id != null) 'id': id,
@@ -62,6 +65,7 @@ class ProductDto {
}; };
} }
/// DTO를 도메인 [Product] 엔티티로 변환한다.
Product toEntity() => Product( Product toEntity() => Product(
id: id, id: id,
productCode: productCode, productCode: productCode,
@@ -75,6 +79,7 @@ class ProductDto {
updatedAt: updatedAt, updatedAt: updatedAt,
); );
/// 엔티티 값을 DTO로 역변환한다.
static ProductDto fromEntity(Product entity) => ProductDto( static ProductDto fromEntity(Product entity) => ProductDto(
id: entity.id, id: entity.id,
productCode: entity.productCode, productCode: entity.productCode,
@@ -96,6 +101,7 @@ class ProductDto {
updatedAt: entity.updatedAt, updatedAt: entity.updatedAt,
); );
/// 페이징 응답을 [PaginatedResult]로 변환한다.
static PaginatedResult<Product> parsePaginated(Map<String, dynamic>? json) { static PaginatedResult<Product> parsePaginated(Map<String, dynamic>? json) {
final rawItems = JsonUtils.extractList(json, keys: const ['items']); final rawItems = JsonUtils.extractList(json, keys: const ['items']);
final items = rawItems final items = rawItems
@@ -111,6 +117,7 @@ class ProductDto {
} }
} }
/// 제품에 연결된 공급업체 정보를 담는 DTO.
class ProductVendorDto { class ProductVendorDto {
ProductVendorDto({ ProductVendorDto({
required this.id, required this.id,
@@ -122,6 +129,7 @@ class ProductVendorDto {
final String vendorCode; final String vendorCode;
final String vendorName; final String vendorName;
/// JSON에서 공급업체 정보를 파싱한다.
factory ProductVendorDto.fromJson(Map<String, dynamic> json) { factory ProductVendorDto.fromJson(Map<String, dynamic> json) {
return ProductVendorDto( return ProductVendorDto(
id: json['id'] as int, id: json['id'] as int,
@@ -130,20 +138,24 @@ class ProductVendorDto {
); );
} }
/// DTO를 JSON 맵으로 직렬화한다.
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return {'id': id, 'vendor_code': vendorCode, 'vendor_name': vendorName}; return {'id': id, 'vendor_code': vendorCode, 'vendor_name': vendorName};
} }
/// DTO를 [ProductVendor] 엔티티로 변환한다.
ProductVendor toEntity() => ProductVendor toEntity() =>
ProductVendor(id: id, vendorCode: vendorCode, vendorName: vendorName); ProductVendor(id: id, vendorCode: vendorCode, vendorName: vendorName);
} }
/// 제품의 단위(UOM) 정보를 담는 DTO.
class ProductUomDto { class ProductUomDto {
ProductUomDto({required this.id, required this.uomName}); ProductUomDto({required this.id, required this.uomName});
final int id; final int id;
final String uomName; final String uomName;
/// JSON에서 단위 정보를 파싱한다.
factory ProductUomDto.fromJson(Map<String, dynamic> json) { factory ProductUomDto.fromJson(Map<String, dynamic> json) {
return ProductUomDto( return ProductUomDto(
id: json['id'] as int, id: json['id'] as int,
@@ -151,13 +163,16 @@ class ProductUomDto {
); );
} }
/// DTO를 JSON 맵으로 직렬화한다.
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return {'id': id, 'uom_name': uomName}; return {'id': id, 'uom_name': uomName};
} }
/// DTO를 [ProductUom] 엔티티로 변환한다.
ProductUom toEntity() => ProductUom(id: id, uomName: uomName); ProductUom toEntity() => ProductUom(id: id, uomName: uomName);
} }
/// 문자열/DateTime을 파싱해 [DateTime]으로 변환한다.
DateTime? _parseDate(Object? value) { DateTime? _parseDate(Object? value) {
if (value == null) return null; if (value == null) return null;
if (value is DateTime) return value; if (value is DateTime) return value;
@@ -165,6 +180,7 @@ DateTime? _parseDate(Object? value) {
return null; return null;
} }
/// 제품 입력 모델을 API 요청 바디로 변환한다.
Map<String, dynamic> productInputToJson(ProductInput input) { Map<String, dynamic> productInputToJson(ProductInput input) {
final map = input.toPayload(); final map = input.toPayload();
map.removeWhere((key, value) => value == null); map.removeWhere((key, value) => value == null);

View File

@@ -6,6 +6,7 @@ import '../../domain/entities/product.dart';
import '../../domain/repositories/product_repository.dart'; import '../../domain/repositories/product_repository.dart';
import '../dtos/product_dto.dart'; import '../dtos/product_dto.dart';
/// 제품 마스터 API를 호출하는 원격 저장소.
class ProductRepositoryRemote implements ProductRepository { class ProductRepositoryRemote implements ProductRepository {
ProductRepositoryRemote({required ApiClient apiClient}) : _api = apiClient; ProductRepositoryRemote({required ApiClient apiClient}) : _api = apiClient;
@@ -13,6 +14,7 @@ class ProductRepositoryRemote implements ProductRepository {
static const _basePath = '/products'; static const _basePath = '/products';
/// 제품 목록을 조회한다.
@override @override
Future<PaginatedResult<Product>> list({ Future<PaginatedResult<Product>> list({
int page = 1, int page = 1,
@@ -38,6 +40,7 @@ class ProductRepositoryRemote implements ProductRepository {
return ProductDto.parsePaginated(response.data ?? const {}); return ProductDto.parsePaginated(response.data ?? const {});
} }
/// 제품을 생성한다.
@override @override
Future<Product> create(ProductInput input) async { Future<Product> create(ProductInput input) async {
final response = await _api.post<Map<String, dynamic>>( final response = await _api.post<Map<String, dynamic>>(
@@ -49,6 +52,7 @@ class ProductRepositoryRemote implements ProductRepository {
return ProductDto.fromJson(data).toEntity(); return ProductDto.fromJson(data).toEntity();
} }
/// 제품 정보를 수정한다.
@override @override
Future<Product> update(int id, ProductInput input) async { Future<Product> update(int id, ProductInput input) async {
final response = await _api.patch<Map<String, dynamic>>( final response = await _api.patch<Map<String, dynamic>>(
@@ -60,11 +64,13 @@ class ProductRepositoryRemote implements ProductRepository {
return ProductDto.fromJson(data).toEntity(); return ProductDto.fromJson(data).toEntity();
} }
/// 제품을 삭제한다.
@override @override
Future<void> delete(int id) async { Future<void> delete(int id) async {
await _api.delete<void>('$_basePath/$id'); await _api.delete<void>('$_basePath/$id');
} }
/// 삭제된 제품을 복구한다.
@override @override
Future<Product> restore(int id) async { Future<Product> restore(int id) async {
final response = await _api.post<Map<String, dynamic>>( final response = await _api.post<Map<String, dynamic>>(

View File

@@ -1,3 +1,4 @@
/// 제품(Product) 도메인 엔티티.
class Product { class Product {
Product({ Product({
this.id, this.id,
@@ -23,6 +24,7 @@ class Product {
final DateTime? createdAt; final DateTime? createdAt;
final DateTime? updatedAt; final DateTime? updatedAt;
/// 일부 속성을 변경한 새 인스턴스를 반환한다.
Product copyWith({ Product copyWith({
int? id, int? id,
String? productCode, String? productCode,
@@ -50,6 +52,7 @@ class Product {
} }
} }
/// 제품에 연결된 공급업체 정보.
class ProductVendor { class ProductVendor {
ProductVendor({ ProductVendor({
required this.id, required this.id,
@@ -62,6 +65,7 @@ class ProductVendor {
final String vendorName; final String vendorName;
} }
/// 제품의 단위(UOM) 정보.
class ProductUom { class ProductUom {
ProductUom({required this.id, required this.uomName}); ProductUom({required this.id, required this.uomName});
@@ -69,6 +73,7 @@ class ProductUom {
final String uomName; final String uomName;
} }
/// 제품 생성/수정에 사용하는 입력 모델.
class ProductInput { class ProductInput {
ProductInput({ ProductInput({
required this.productCode, required this.productCode,
@@ -86,6 +91,7 @@ class ProductInput {
final bool isActive; final bool isActive;
final String? note; final String? note;
/// API 요청 바디로 직렬화한다.
Map<String, dynamic> toPayload() { Map<String, dynamic> toPayload() {
return { return {
'product_code': productCode, 'product_code': productCode,

View File

@@ -2,7 +2,9 @@ import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../entities/product.dart'; import '../entities/product.dart';
/// 제품 데이터를 다루는 도메인 저장소 인터페이스.
abstract class ProductRepository { abstract class ProductRepository {
/// 제품 목록을 조회한다.
Future<PaginatedResult<Product>> list({ Future<PaginatedResult<Product>> list({
int page = 1, int page = 1,
int pageSize = 20, int pageSize = 20,
@@ -12,11 +14,15 @@ abstract class ProductRepository {
bool? isActive, bool? isActive,
}); });
/// 제품을 생성한다.
Future<Product> create(ProductInput input); Future<Product> create(ProductInput input);
/// 제품을 수정한다.
Future<Product> update(int id, ProductInput input); Future<Product> update(int id, ProductInput input);
/// 제품을 삭제한다.
Future<void> delete(int id); Future<void> delete(int id);
/// 삭제된 제품을 복구한다.
Future<Product> restore(int id); Future<Product> restore(int id);
} }

View File

@@ -8,8 +8,10 @@ import '../../../uom/domain/repositories/uom_repository.dart';
import '../../domain/entities/product.dart'; import '../../domain/entities/product.dart';
import '../../domain/repositories/product_repository.dart'; import '../../domain/repositories/product_repository.dart';
/// 제품 사용 여부 필터.
enum ProductStatusFilter { all, activeOnly, inactiveOnly } enum ProductStatusFilter { all, activeOnly, inactiveOnly }
/// 제품 마스터 화면 상태를 관리하는 컨트롤러.
class ProductController extends ChangeNotifier { class ProductController extends ChangeNotifier {
static const int defaultPageSize = 20; static const int defaultPageSize = 20;
@@ -52,6 +54,7 @@ class ProductController extends ChangeNotifier {
List<Vendor> get vendorOptions => _vendorOptions; List<Vendor> get vendorOptions => _vendorOptions;
List<Uom> get uomOptions => _uomOptions; List<Uom> get uomOptions => _uomOptions;
/// 제품 목록을 조회한다.
Future<void> fetch({int page = 1}) async { Future<void> fetch({int page = 1}) async {
_isLoading = true; _isLoading = true;
_errorMessage = null; _errorMessage = null;
@@ -82,6 +85,7 @@ class ProductController extends ChangeNotifier {
} }
} }
/// 필터/폼에서 사용할 공급업체와 단위 목록을 로드한다.
Future<void> loadLookups() async { Future<void> loadLookups() async {
_isLoadingLookups = true; _isLoadingLookups = true;
notifyListeners(); notifyListeners();
@@ -98,6 +102,7 @@ class ProductController extends ChangeNotifier {
} }
} }
/// 검색어를 변경한다.
void updateQuery(String value) { void updateQuery(String value) {
if (_query == value) { if (_query == value) {
return; return;
@@ -106,6 +111,7 @@ class ProductController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// 공급업체 필터를 변경한다.
void updateVendorFilter(int? vendorId) { void updateVendorFilter(int? vendorId) {
if (_vendorFilter == vendorId) { if (_vendorFilter == vendorId) {
return; return;
@@ -114,6 +120,7 @@ class ProductController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// 단위(UOM) 필터를 변경한다.
void updateUomFilter(int? uomId) { void updateUomFilter(int? uomId) {
if (_uomFilter == uomId) { if (_uomFilter == uomId) {
return; return;
@@ -122,6 +129,7 @@ class ProductController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// 사용 여부 필터를 변경한다.
void updateStatusFilter(ProductStatusFilter filter) { void updateStatusFilter(ProductStatusFilter filter) {
if (_statusFilter == filter) { if (_statusFilter == filter) {
return; return;
@@ -130,6 +138,7 @@ class ProductController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// 페이지 크기를 변경한다.
void updatePageSize(int size) { void updatePageSize(int size) {
if (size <= 0 || _pageSize == size) { if (size <= 0 || _pageSize == size) {
return; return;
@@ -138,6 +147,7 @@ class ProductController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// 제품을 생성한다.
Future<Product?> create(ProductInput input) async { Future<Product?> create(ProductInput input) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -153,6 +163,7 @@ class ProductController extends ChangeNotifier {
} }
} }
/// 제품 정보를 수정한다.
Future<Product?> update(int id, ProductInput input) async { Future<Product?> update(int id, ProductInput input) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -168,6 +179,7 @@ class ProductController extends ChangeNotifier {
} }
} }
/// 제품을 삭제한다.
Future<bool> delete(int id) async { Future<bool> delete(int id) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -183,6 +195,7 @@ class ProductController extends ChangeNotifier {
} }
} }
/// 삭제된 제품을 복구한다.
Future<Product?> restore(int id) async { Future<Product?> restore(int id) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -198,11 +211,13 @@ class ProductController extends ChangeNotifier {
} }
} }
/// 에러 메시지를 초기화한다.
void clearError() { void clearError() {
_errorMessage = null; _errorMessage = null;
notifyListeners(); notifyListeners();
} }
/// 제출 상태 플래그를 갱신하고 리스너에 알린다.
void _setSubmitting(bool value) { void _setSubmitting(bool value) {
_isSubmitting = value; _isSubmitting = value;
notifyListeners(); notifyListeners();

View File

@@ -19,6 +19,7 @@ import '../../domain/entities/product.dart';
import '../../domain/repositories/product_repository.dart'; import '../../domain/repositories/product_repository.dart';
import '../controllers/product_controller.dart'; import '../controllers/product_controller.dart';
/// 제품 관리 페이지. 기능 플래그에 따라 사양/실제 화면을 전환한다.
class ProductPage extends StatelessWidget { class ProductPage extends StatelessWidget {
const ProductPage({super.key, required this.routeUri}); const ProductPage({super.key, required this.routeUri});
@@ -74,6 +75,7 @@ class ProductPage extends StatelessWidget {
} }
} }
/// 제품 기능이 활성화된 경우 사용하는 실제 화면 위젯.
class _ProductEnabledPage extends StatefulWidget { class _ProductEnabledPage extends StatefulWidget {
const _ProductEnabledPage({required this.routeUri}); const _ProductEnabledPage({required this.routeUri});
@@ -83,6 +85,7 @@ class _ProductEnabledPage extends StatefulWidget {
State<_ProductEnabledPage> createState() => _ProductEnabledPageState(); State<_ProductEnabledPage> createState() => _ProductEnabledPageState();
} }
/// 제품 목록과 필터/폼 상태를 관리하는 상태 클래스.
class _ProductEnabledPageState extends State<_ProductEnabledPage> { class _ProductEnabledPageState extends State<_ProductEnabledPage> {
late final ProductController _controller; late final ProductController _controller;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();

View File

@@ -3,6 +3,7 @@ import 'package:superport_v2/core/common/utils/json_utils.dart';
import '../../domain/entities/uom.dart'; import '../../domain/entities/uom.dart';
/// 단위(UOM) API 응답을 표현하는 DTO.
class UomDto { class UomDto {
UomDto({ UomDto({
this.id, this.id,
@@ -24,6 +25,7 @@ class UomDto {
final DateTime? createdAt; final DateTime? createdAt;
final DateTime? updatedAt; final DateTime? updatedAt;
/// JSON에서 단위 정보를 파싱한다.
factory UomDto.fromJson(Map<String, dynamic> json) { factory UomDto.fromJson(Map<String, dynamic> json) {
return UomDto( return UomDto(
id: json['id'] as int?, id: json['id'] as int?,
@@ -37,6 +39,7 @@ class UomDto {
); );
} }
/// DTO를 도메인 [Uom] 엔티티로 변환한다.
Uom toEntity() => Uom( Uom toEntity() => Uom(
id: id, id: id,
uomName: uomName, uomName: uomName,
@@ -48,6 +51,7 @@ class UomDto {
updatedAt: updatedAt, updatedAt: updatedAt,
); );
/// 페이징 응답을 [PaginatedResult]로 변환한다.
static PaginatedResult<Uom> parsePaginated(Map<String, dynamic>? json) { static PaginatedResult<Uom> parsePaginated(Map<String, dynamic>? json) {
final rawItems = JsonUtils.extractList(json, keys: const ['items']); final rawItems = JsonUtils.extractList(json, keys: const ['items']);
final items = rawItems final items = rawItems
@@ -63,6 +67,7 @@ class UomDto {
} }
} }
/// 문자열/DateTime을 파싱해 [DateTime]으로 변환한다.
DateTime? _parseDate(Object? value) { DateTime? _parseDate(Object? value) {
if (value == null) return null; if (value == null) return null;
if (value is DateTime) return value; if (value is DateTime) return value;

View File

@@ -6,6 +6,7 @@ import '../../domain/entities/uom.dart';
import '../../domain/repositories/uom_repository.dart'; import '../../domain/repositories/uom_repository.dart';
import '../dtos/uom_dto.dart'; import '../dtos/uom_dto.dart';
/// 단위(UOM) 마스터 API를 호출하는 원격 저장소.
class UomRepositoryRemote implements UomRepository { class UomRepositoryRemote implements UomRepository {
UomRepositoryRemote({required ApiClient apiClient}) : _api = apiClient; UomRepositoryRemote({required ApiClient apiClient}) : _api = apiClient;
@@ -13,6 +14,7 @@ class UomRepositoryRemote implements UomRepository {
static const _basePath = '/uoms'; static const _basePath = '/uoms';
/// UOM 목록을 조회한다.
@override @override
Future<PaginatedResult<Uom>> list({ Future<PaginatedResult<Uom>> list({
int page = 1, int page = 1,

View File

@@ -1,3 +1,4 @@
/// 단위(UOM) 도메인 엔티티.
class Uom { class Uom {
Uom({ Uom({
this.id, this.id,
@@ -19,6 +20,7 @@ class Uom {
final DateTime? createdAt; final DateTime? createdAt;
final DateTime? updatedAt; final DateTime? updatedAt;
/// 선택한 속성만 변경한 새 인스턴스를 반환한다.
Uom copyWith({ Uom copyWith({
int? id, int? id,
String? uomName, String? uomName,

View File

@@ -3,6 +3,7 @@ import 'package:superport_v2/core/common/utils/json_utils.dart';
import '../../domain/entities/user.dart'; import '../../domain/entities/user.dart';
/// 사용자(User) API 응답을 표현하는 DTO.
class UserDto { class UserDto {
UserDto({ UserDto({
this.id, this.id,
@@ -30,6 +31,7 @@ class UserDto {
final DateTime? createdAt; final DateTime? createdAt;
final DateTime? updatedAt; final DateTime? updatedAt;
/// JSON에서 사용자 정보를 파싱한다.
factory UserDto.fromJson(Map<String, dynamic> json) { factory UserDto.fromJson(Map<String, dynamic> json) {
return UserDto( return UserDto(
id: json['id'] as int?, id: json['id'] as int?,
@@ -48,6 +50,7 @@ class UserDto {
); );
} }
/// DTO를 도메인 [UserAccount] 엔티티로 변환한다.
UserAccount toEntity() => UserAccount( UserAccount toEntity() => UserAccount(
id: id, id: id,
employeeNo: employeeNo, employeeNo: employeeNo,
@@ -62,6 +65,7 @@ class UserDto {
updatedAt: updatedAt, updatedAt: updatedAt,
); );
/// 페이징 응답을 [PaginatedResult]로 변환한다.
static PaginatedResult<UserAccount> parsePaginated( static PaginatedResult<UserAccount> parsePaginated(
Map<String, dynamic>? json, Map<String, dynamic>? json,
) { ) {
@@ -79,12 +83,14 @@ class UserDto {
} }
} }
/// 사용자에 연결된 그룹 정보를 담는 DTO.
class UserGroupDto { class UserGroupDto {
UserGroupDto({required this.id, required this.groupName}); UserGroupDto({required this.id, required this.groupName});
final int id; final int id;
final String groupName; final String groupName;
/// JSON에서 그룹 정보를 파싱한다.
factory UserGroupDto.fromJson(Map<String, dynamic> json) { factory UserGroupDto.fromJson(Map<String, dynamic> json) {
return UserGroupDto( return UserGroupDto(
id: json['id'] as int, id: json['id'] as int,
@@ -92,9 +98,11 @@ class UserGroupDto {
); );
} }
/// DTO를 [UserGroup] 엔티티로 변환한다.
UserGroup toEntity() => UserGroup(id: id, groupName: groupName); UserGroup toEntity() => UserGroup(id: id, groupName: groupName);
} }
/// 문자열/DateTime을 파싱해 [DateTime]으로 변환한다.
DateTime? _parseDate(Object? value) { DateTime? _parseDate(Object? value) {
if (value == null) return null; if (value == null) return null;
if (value is DateTime) return value; if (value is DateTime) return value;

View File

@@ -6,6 +6,7 @@ import '../../domain/entities/user.dart';
import '../../domain/repositories/user_repository.dart'; import '../../domain/repositories/user_repository.dart';
import '../dtos/user_dto.dart'; import '../dtos/user_dto.dart';
/// 사용자 마스터 API를 호출하는 원격 저장소.
class UserRepositoryRemote implements UserRepository { class UserRepositoryRemote implements UserRepository {
UserRepositoryRemote({required ApiClient apiClient}) : _api = apiClient; UserRepositoryRemote({required ApiClient apiClient}) : _api = apiClient;
@@ -13,6 +14,7 @@ class UserRepositoryRemote implements UserRepository {
static const _basePath = '/employees'; static const _basePath = '/employees';
/// 사용자 목록을 조회한다.
@override @override
Future<PaginatedResult<UserAccount>> list({ Future<PaginatedResult<UserAccount>> list({
int page = 1, int page = 1,
@@ -36,6 +38,7 @@ class UserRepositoryRemote implements UserRepository {
return UserDto.parsePaginated(response.data ?? const {}); return UserDto.parsePaginated(response.data ?? const {});
} }
/// 사용자를 생성한다.
@override @override
Future<UserAccount> create(UserInput input) async { Future<UserAccount> create(UserInput input) async {
final response = await _api.post<Map<String, dynamic>>( final response = await _api.post<Map<String, dynamic>>(
@@ -47,6 +50,7 @@ class UserRepositoryRemote implements UserRepository {
return UserDto.fromJson(data).toEntity(); return UserDto.fromJson(data).toEntity();
} }
/// 사용자 정보를 수정한다.
@override @override
Future<UserAccount> update(int id, UserInput input) async { Future<UserAccount> update(int id, UserInput input) async {
final response = await _api.patch<Map<String, dynamic>>( final response = await _api.patch<Map<String, dynamic>>(
@@ -58,11 +62,13 @@ class UserRepositoryRemote implements UserRepository {
return UserDto.fromJson(data).toEntity(); return UserDto.fromJson(data).toEntity();
} }
/// 사용자를 삭제한다.
@override @override
Future<void> delete(int id) async { Future<void> delete(int id) async {
await _api.delete<void>('$_basePath/$id'); await _api.delete<void>('$_basePath/$id');
} }
/// 삭제된 사용자를 복구한다.
@override @override
Future<UserAccount> restore(int id) async { Future<UserAccount> restore(int id) async {
final response = await _api.post<Map<String, dynamic>>( final response = await _api.post<Map<String, dynamic>>(

View File

@@ -1,3 +1,4 @@
/// 사용자(User) 도메인 엔티티.
class UserAccount { class UserAccount {
UserAccount({ UserAccount({
this.id, this.id,
@@ -25,6 +26,7 @@ class UserAccount {
final DateTime? createdAt; final DateTime? createdAt;
final DateTime? updatedAt; final DateTime? updatedAt;
/// 선택된 속성만 변경한 새 인스턴스를 반환한다.
UserAccount copyWith({ UserAccount copyWith({
int? id, int? id,
String? employeeNo, String? employeeNo,
@@ -54,6 +56,7 @@ class UserAccount {
} }
} }
/// 사용자에 연결된 그룹 정보.
class UserGroup { class UserGroup {
UserGroup({required this.id, required this.groupName}); UserGroup({required this.id, required this.groupName});
@@ -61,6 +64,7 @@ class UserGroup {
final String groupName; final String groupName;
} }
/// 사용자 생성/수정 입력 모델.
class UserInput { class UserInput {
UserInput({ UserInput({
required this.employeeNo, required this.employeeNo,
@@ -80,6 +84,7 @@ class UserInput {
final bool isActive; final bool isActive;
final String? note; final String? note;
/// API 요청 바디로 직렬화한다.
Map<String, dynamic> toPayload() { Map<String, dynamic> toPayload() {
return { return {
'employee_no': employeeNo, 'employee_no': employeeNo,

View File

@@ -2,7 +2,9 @@ import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../entities/user.dart'; import '../entities/user.dart';
/// 사용자 데이터를 다루는 도메인 저장소 인터페이스.
abstract class UserRepository { abstract class UserRepository {
/// 사용자 목록을 조회한다.
Future<PaginatedResult<UserAccount>> list({ Future<PaginatedResult<UserAccount>> list({
int page = 1, int page = 1,
int pageSize = 20, int pageSize = 20,
@@ -11,11 +13,15 @@ abstract class UserRepository {
bool? isActive, bool? isActive,
}); });
/// 사용자를 생성한다.
Future<UserAccount> create(UserInput input); Future<UserAccount> create(UserInput input);
/// 사용자 정보를 수정한다.
Future<UserAccount> update(int id, UserInput input); Future<UserAccount> update(int id, UserInput input);
/// 사용자를 삭제한다.
Future<void> delete(int id); Future<void> delete(int id);
/// 삭제된 사용자를 복구한다.
Future<UserAccount> restore(int id); Future<UserAccount> restore(int id);
} }

View File

@@ -6,8 +6,10 @@ import '../../../group/domain/repositories/group_repository.dart';
import '../../domain/entities/user.dart'; import '../../domain/entities/user.dart';
import '../../domain/repositories/user_repository.dart'; import '../../domain/repositories/user_repository.dart';
/// 사용자 활성 여부 필터.
enum UserStatusFilter { all, activeOnly, inactiveOnly } enum UserStatusFilter { all, activeOnly, inactiveOnly }
/// 사용자 마스터 화면 상태를 관리하는 컨트롤러.
class UserController extends ChangeNotifier { class UserController extends ChangeNotifier {
UserController({ UserController({
required UserRepository userRepository, required UserRepository userRepository,
@@ -38,6 +40,7 @@ class UserController extends ChangeNotifier {
String? get errorMessage => _errorMessage; String? get errorMessage => _errorMessage;
List<Group> get groups => _groups; List<Group> get groups => _groups;
/// 권한 그룹 목록을 로드한다.
Future<void> loadGroups() async { Future<void> loadGroups() async {
_isLoadingGroups = true; _isLoadingGroups = true;
notifyListeners(); notifyListeners();
@@ -52,6 +55,7 @@ class UserController extends ChangeNotifier {
} }
} }
/// 사용자 목록을 조회한다.
Future<void> fetch({int page = 1}) async { Future<void> fetch({int page = 1}) async {
_isLoading = true; _isLoading = true;
_errorMessage = null; _errorMessage = null;
@@ -78,21 +82,25 @@ class UserController extends ChangeNotifier {
} }
} }
/// 검색어를 변경한다.
void updateQuery(String value) { void updateQuery(String value) {
_query = value; _query = value;
notifyListeners(); notifyListeners();
} }
/// 그룹 필터를 변경한다.
void updateGroupFilter(int? groupId) { void updateGroupFilter(int? groupId) {
_groupFilter = groupId; _groupFilter = groupId;
notifyListeners(); notifyListeners();
} }
/// 사용자 상태 필터를 변경한다.
void updateStatusFilter(UserStatusFilter filter) { void updateStatusFilter(UserStatusFilter filter) {
_statusFilter = filter; _statusFilter = filter;
notifyListeners(); notifyListeners();
} }
/// 사용자를 생성한다.
Future<UserAccount?> create(UserInput input) async { Future<UserAccount?> create(UserInput input) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -108,6 +116,7 @@ class UserController extends ChangeNotifier {
} }
} }
/// 사용자 정보를 수정한다.
Future<UserAccount?> update(int id, UserInput input) async { Future<UserAccount?> update(int id, UserInput input) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -123,6 +132,7 @@ class UserController extends ChangeNotifier {
} }
} }
/// 사용자를 삭제한다.
Future<bool> delete(int id) async { Future<bool> delete(int id) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -138,6 +148,7 @@ class UserController extends ChangeNotifier {
} }
} }
/// 삭제된 사용자를 복구한다.
Future<UserAccount?> restore(int id) async { Future<UserAccount?> restore(int id) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -153,11 +164,13 @@ class UserController extends ChangeNotifier {
} }
} }
/// 에러 메시지를 초기화한다.
void clearError() { void clearError() {
_errorMessage = null; _errorMessage = null;
notifyListeners(); notifyListeners();
} }
/// 제출 상태 플래그를 갱신하고 리스너에게 알린다.
void _setSubmitting(bool value) { void _setSubmitting(bool value) {
_isSubmitting = value; _isSubmitting = value;
notifyListeners(); notifyListeners();

View File

@@ -15,6 +15,7 @@ import '../../domain/entities/user.dart';
import '../../domain/repositories/user_repository.dart'; import '../../domain/repositories/user_repository.dart';
import '../controllers/user_controller.dart'; import '../controllers/user_controller.dart';
/// 사용자 관리 페이지. 기능 플래그에 따라 사양/실제 화면을 보여준다.
class UserPage extends StatelessWidget { class UserPage extends StatelessWidget {
const UserPage({super.key}); const UserPage({super.key});
@@ -80,6 +81,7 @@ class UserPage extends StatelessWidget {
} }
} }
/// 사용자 기능이 활성화된 경우 사용하는 실제 화면 위젯.
class _UserEnabledPage extends StatefulWidget { class _UserEnabledPage extends StatefulWidget {
const _UserEnabledPage(); const _UserEnabledPage();
@@ -87,6 +89,7 @@ class _UserEnabledPage extends StatefulWidget {
State<_UserEnabledPage> createState() => _UserEnabledPageState(); State<_UserEnabledPage> createState() => _UserEnabledPageState();
} }
/// 사용자 목록과 필터 상태를 관리하는 상태 클래스.
class _UserEnabledPageState extends State<_UserEnabledPage> { class _UserEnabledPageState extends State<_UserEnabledPage> {
late final UserController _controller; late final UserController _controller;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();

View File

@@ -15,6 +15,7 @@ import '../../../vendor/domain/entities/vendor.dart';
import '../../../vendor/domain/repositories/vendor_repository.dart'; import '../../../vendor/domain/repositories/vendor_repository.dart';
import '../controllers/vendor_controller.dart'; import '../controllers/vendor_controller.dart';
/// 벤더 관리 페이지. 기능 플래그에 따라 사양/실제 화면을 전환한다.
class VendorPage extends StatelessWidget { class VendorPage extends StatelessWidget {
const VendorPage({super.key, required this.routeUri}); const VendorPage({super.key, required this.routeUri});
@@ -67,6 +68,7 @@ class VendorPage extends StatelessWidget {
} }
} }
/// 벤더 기능이 활성화된 경우 사용하는 실제 화면 위젯.
class _VendorEnabledPage extends StatefulWidget { class _VendorEnabledPage extends StatefulWidget {
const _VendorEnabledPage({required this.routeUri}); const _VendorEnabledPage({required this.routeUri});
@@ -76,6 +78,7 @@ class _VendorEnabledPage extends StatefulWidget {
State<_VendorEnabledPage> createState() => _VendorEnabledPageState(); State<_VendorEnabledPage> createState() => _VendorEnabledPageState();
} }
/// 벤더 목록과 필터/폼 상태를 관리하는 상태 클래스.
class _VendorEnabledPageState extends State<_VendorEnabledPage> { class _VendorEnabledPageState extends State<_VendorEnabledPage> {
late final VendorController _controller; late final VendorController _controller;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();

View File

@@ -3,6 +3,7 @@ import 'package:superport_v2/core/common/utils/json_utils.dart';
import '../../domain/entities/warehouse.dart'; import '../../domain/entities/warehouse.dart';
/// 창고(Warehouse) API 응답을 표현하는 DTO.
class WarehouseDto { class WarehouseDto {
WarehouseDto({ WarehouseDto({
this.id, this.id,
@@ -28,6 +29,7 @@ class WarehouseDto {
final DateTime? createdAt; final DateTime? createdAt;
final DateTime? updatedAt; final DateTime? updatedAt;
/// JSON에서 창고 정보를 파싱한다.
factory WarehouseDto.fromJson(Map<String, dynamic> json) { factory WarehouseDto.fromJson(Map<String, dynamic> json) {
return WarehouseDto( return WarehouseDto(
id: json['id'] as int?, id: json['id'] as int?,
@@ -47,6 +49,7 @@ class WarehouseDto {
); );
} }
/// DTO를 JSON 맵으로 직렬화한다.
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
if (id != null) 'id': id, if (id != null) 'id': id,
@@ -62,6 +65,7 @@ class WarehouseDto {
}; };
} }
/// DTO를 도메인 [Warehouse] 엔티티로 변환한다.
Warehouse toEntity() => Warehouse( Warehouse toEntity() => Warehouse(
id: id, id: id,
warehouseCode: warehouseCode, warehouseCode: warehouseCode,
@@ -75,6 +79,7 @@ class WarehouseDto {
updatedAt: updatedAt, updatedAt: updatedAt,
); );
/// 페이징 응답을 [PaginatedResult]로 변환한다.
static PaginatedResult<Warehouse> parsePaginated(Map<String, dynamic>? json) { static PaginatedResult<Warehouse> parsePaginated(Map<String, dynamic>? json) {
final rawItems = JsonUtils.extractList(json, keys: const ['items']); final rawItems = JsonUtils.extractList(json, keys: const ['items']);
final items = rawItems final items = rawItems
@@ -90,6 +95,7 @@ class WarehouseDto {
} }
} }
/// 창고 주소에 대한 우편번호 정보를 담는 DTO.
class WarehouseZipcodeDto { class WarehouseZipcodeDto {
WarehouseZipcodeDto({ WarehouseZipcodeDto({
required this.zipcode, required this.zipcode,
@@ -103,6 +109,7 @@ class WarehouseZipcodeDto {
final String? sigungu; final String? sigungu;
final String? roadName; final String? roadName;
/// JSON에서 우편번호 정보를 파싱한다.
factory WarehouseZipcodeDto.fromJson(Map<String, dynamic> json) { factory WarehouseZipcodeDto.fromJson(Map<String, dynamic> json) {
return WarehouseZipcodeDto( return WarehouseZipcodeDto(
zipcode: json['zipcode'] as String, zipcode: json['zipcode'] as String,
@@ -112,6 +119,7 @@ class WarehouseZipcodeDto {
); );
} }
/// DTO를 JSON 맵으로 직렬화한다.
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
'zipcode': zipcode, 'zipcode': zipcode,
@@ -121,6 +129,7 @@ class WarehouseZipcodeDto {
}; };
} }
/// DTO를 [WarehouseZipcode] 엔티티로 변환한다.
WarehouseZipcode toEntity() => WarehouseZipcode( WarehouseZipcode toEntity() => WarehouseZipcode(
zipcode: zipcode, zipcode: zipcode,
sido: sido, sido: sido,
@@ -129,6 +138,7 @@ class WarehouseZipcodeDto {
); );
} }
/// 문자열/DateTime 값을 파싱해 [DateTime]으로 변환한다.
DateTime? _parseDate(Object? value) { DateTime? _parseDate(Object? value) {
if (value == null) return null; if (value == null) return null;
if (value is DateTime) return value; if (value is DateTime) return value;
@@ -136,6 +146,7 @@ DateTime? _parseDate(Object? value) {
return null; return null;
} }
/// 창고 입력 모델을 API 요청 바디로 변환한다.
Map<String, dynamic> warehouseInputToJson(WarehouseInput input) { Map<String, dynamic> warehouseInputToJson(WarehouseInput input) {
final map = input.toPayload(); final map = input.toPayload();
map.removeWhere((key, value) => value == null); map.removeWhere((key, value) => value == null);

View File

@@ -6,6 +6,7 @@ import '../../domain/entities/warehouse.dart';
import '../../domain/repositories/warehouse_repository.dart'; import '../../domain/repositories/warehouse_repository.dart';
import '../dtos/warehouse_dto.dart'; import '../dtos/warehouse_dto.dart';
/// 창고(Warehouse) 마스터 API를 호출하는 원격 저장소.
class WarehouseRepositoryRemote implements WarehouseRepository { class WarehouseRepositoryRemote implements WarehouseRepository {
WarehouseRepositoryRemote({required ApiClient apiClient}) : _api = apiClient; WarehouseRepositoryRemote({required ApiClient apiClient}) : _api = apiClient;
@@ -13,6 +14,7 @@ class WarehouseRepositoryRemote implements WarehouseRepository {
static const _basePath = '/warehouses'; static const _basePath = '/warehouses';
/// 창고 목록을 조회한다.
@override @override
Future<PaginatedResult<Warehouse>> list({ Future<PaginatedResult<Warehouse>> list({
int page = 1, int page = 1,
@@ -33,6 +35,7 @@ class WarehouseRepositoryRemote implements WarehouseRepository {
return WarehouseDto.parsePaginated(response.data ?? const {}); return WarehouseDto.parsePaginated(response.data ?? const {});
} }
/// 창고를 생성한다.
@override @override
Future<Warehouse> create(WarehouseInput input) async { Future<Warehouse> create(WarehouseInput input) async {
final response = await _api.post<Map<String, dynamic>>( final response = await _api.post<Map<String, dynamic>>(
@@ -44,6 +47,7 @@ class WarehouseRepositoryRemote implements WarehouseRepository {
return WarehouseDto.fromJson(data).toEntity(); return WarehouseDto.fromJson(data).toEntity();
} }
/// 창고 정보를 수정한다.
@override @override
Future<Warehouse> update(int id, WarehouseInput input) async { Future<Warehouse> update(int id, WarehouseInput input) async {
final response = await _api.patch<Map<String, dynamic>>( final response = await _api.patch<Map<String, dynamic>>(
@@ -55,11 +59,13 @@ class WarehouseRepositoryRemote implements WarehouseRepository {
return WarehouseDto.fromJson(data).toEntity(); return WarehouseDto.fromJson(data).toEntity();
} }
/// 창고를 삭제한다.
@override @override
Future<void> delete(int id) async { Future<void> delete(int id) async {
await _api.delete<void>('$_basePath/$id'); await _api.delete<void>('$_basePath/$id');
} }
/// 삭제된 창고를 복구한다.
@override @override
Future<Warehouse> restore(int id) async { Future<Warehouse> restore(int id) async {
final response = await _api.post<Map<String, dynamic>>( final response = await _api.post<Map<String, dynamic>>(

View File

@@ -1,3 +1,4 @@
/// 창고(Warehouse) 도메인 엔티티.
class Warehouse { class Warehouse {
Warehouse({ Warehouse({
this.id, this.id,
@@ -23,6 +24,7 @@ class Warehouse {
final DateTime? createdAt; final DateTime? createdAt;
final DateTime? updatedAt; final DateTime? updatedAt;
/// 일부 속성만 변경한 새 인스턴스를 반환한다.
Warehouse copyWith({ Warehouse copyWith({
int? id, int? id,
String? warehouseCode, String? warehouseCode,
@@ -50,6 +52,7 @@ class Warehouse {
} }
} }
/// 창고 주소에 대한 우편번호/행정 정보.
class WarehouseZipcode { class WarehouseZipcode {
WarehouseZipcode({ WarehouseZipcode({
required this.zipcode, required this.zipcode,
@@ -64,6 +67,7 @@ class WarehouseZipcode {
final String? roadName; final String? roadName;
} }
/// 창고 생성/수정에 사용하는 입력 모델.
class WarehouseInput { class WarehouseInput {
WarehouseInput({ WarehouseInput({
required this.warehouseCode, required this.warehouseCode,
@@ -81,6 +85,7 @@ class WarehouseInput {
final bool isActive; final bool isActive;
final String? note; final String? note;
/// API 요청 바디로 직렬화한다.
Map<String, dynamic> toPayload() { Map<String, dynamic> toPayload() {
return { return {
'warehouse_code': warehouseCode, 'warehouse_code': warehouseCode,

View File

@@ -2,7 +2,9 @@ import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../entities/warehouse.dart'; import '../entities/warehouse.dart';
/// 창고 데이터를 다루는 도메인 저장소 인터페이스.
abstract class WarehouseRepository { abstract class WarehouseRepository {
/// 창고 목록을 조회한다.
Future<PaginatedResult<Warehouse>> list({ Future<PaginatedResult<Warehouse>> list({
int page = 1, int page = 1,
int pageSize = 20, int pageSize = 20,
@@ -10,11 +12,15 @@ abstract class WarehouseRepository {
bool? isActive, bool? isActive,
}); });
/// 창고를 생성한다.
Future<Warehouse> create(WarehouseInput input); Future<Warehouse> create(WarehouseInput input);
/// 창고 정보를 수정한다.
Future<Warehouse> update(int id, WarehouseInput input); Future<Warehouse> update(int id, WarehouseInput input);
/// 창고를 삭제한다.
Future<void> delete(int id); Future<void> delete(int id);
/// 삭제된 창고를 복구한다.
Future<Warehouse> restore(int id); Future<Warehouse> restore(int id);
} }

View File

@@ -4,8 +4,10 @@ import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../../domain/entities/warehouse.dart'; import '../../domain/entities/warehouse.dart';
import '../../domain/repositories/warehouse_repository.dart'; import '../../domain/repositories/warehouse_repository.dart';
/// 창고 사용 여부 필터.
enum WarehouseStatusFilter { all, activeOnly, inactiveOnly } enum WarehouseStatusFilter { all, activeOnly, inactiveOnly }
/// 창고 마스터 화면 상태를 관리하는 컨트롤러.
class WarehouseController extends ChangeNotifier { class WarehouseController extends ChangeNotifier {
static const int defaultPageSize = 20; static const int defaultPageSize = 20;
@@ -30,6 +32,7 @@ class WarehouseController extends ChangeNotifier {
int get pageSize => _pageSize; int get pageSize => _pageSize;
String? get errorMessage => _errorMessage; String? get errorMessage => _errorMessage;
/// 창고 목록을 조회한다.
Future<void> fetch({int page = 1}) async { Future<void> fetch({int page = 1}) async {
_isLoading = true; _isLoading = true;
_errorMessage = null; _errorMessage = null;
@@ -58,6 +61,7 @@ class WarehouseController extends ChangeNotifier {
} }
} }
/// 검색어를 변경한다.
void updateQuery(String value) { void updateQuery(String value) {
if (_query == value) { if (_query == value) {
return; return;
@@ -66,6 +70,7 @@ class WarehouseController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// 사용 여부 필터를 변경한다.
void updateStatusFilter(WarehouseStatusFilter filter) { void updateStatusFilter(WarehouseStatusFilter filter) {
if (_statusFilter == filter) { if (_statusFilter == filter) {
return; return;
@@ -74,6 +79,7 @@ class WarehouseController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// 페이지 크기를 변경한다.
void updatePageSize(int size) { void updatePageSize(int size) {
if (size <= 0 || _pageSize == size) { if (size <= 0 || _pageSize == size) {
return; return;
@@ -82,6 +88,7 @@ class WarehouseController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// 창고를 생성한다.
Future<Warehouse?> create(WarehouseInput input) async { Future<Warehouse?> create(WarehouseInput input) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -97,6 +104,7 @@ class WarehouseController extends ChangeNotifier {
} }
} }
/// 창고 정보를 수정한다.
Future<Warehouse?> update(int id, WarehouseInput input) async { Future<Warehouse?> update(int id, WarehouseInput input) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -112,6 +120,7 @@ class WarehouseController extends ChangeNotifier {
} }
} }
/// 창고를 삭제한다.
Future<bool> delete(int id) async { Future<bool> delete(int id) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -127,6 +136,7 @@ class WarehouseController extends ChangeNotifier {
} }
} }
/// 삭제된 창고를 복구한다.
Future<Warehouse?> restore(int id) async { Future<Warehouse?> restore(int id) async {
_setSubmitting(true); _setSubmitting(true);
try { try {
@@ -142,11 +152,13 @@ class WarehouseController extends ChangeNotifier {
} }
} }
/// 에러 메시지를 초기화한다.
void clearError() { void clearError() {
_errorMessage = null; _errorMessage = null;
notifyListeners(); notifyListeners();
} }
/// 제출 상태 플래그를 갱신하고 리스너에 알린다.
void _setSubmitting(bool value) { void _setSubmitting(bool value) {
_isSubmitting = value; _isSubmitting = value;
notifyListeners(); notifyListeners();

View File

@@ -17,6 +17,7 @@ import '../../domain/entities/warehouse.dart';
import '../../domain/repositories/warehouse_repository.dart'; import '../../domain/repositories/warehouse_repository.dart';
import '../controllers/warehouse_controller.dart'; import '../controllers/warehouse_controller.dart';
/// 창고 관리 페이지. 기능 플래그에 따라 사양/실제 화면을 전환한다.
class WarehousePage extends StatelessWidget { class WarehousePage extends StatelessWidget {
const WarehousePage({super.key, required this.routeUri}); const WarehousePage({super.key, required this.routeUri});
@@ -81,6 +82,7 @@ class WarehousePage extends StatelessWidget {
} }
} }
/// 창고 기능이 활성화된 경우 사용하는 실제 화면 위젯.
class _WarehouseEnabledPage extends StatefulWidget { class _WarehouseEnabledPage extends StatefulWidget {
const _WarehouseEnabledPage({required this.routeUri}); const _WarehouseEnabledPage({required this.routeUri});
@@ -90,6 +92,7 @@ class _WarehouseEnabledPage extends StatefulWidget {
State<_WarehouseEnabledPage> createState() => _WarehouseEnabledPageState(); State<_WarehouseEnabledPage> createState() => _WarehouseEnabledPageState();
} }
/// 창고 목록과 필터 상태를 관리하는 상태 클래스.
class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
late final WarehouseController _controller; late final WarehouseController _controller;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();

View File

@@ -13,6 +13,7 @@ import 'package:superport_v2/widgets/components/feedback.dart';
import 'package:superport_v2/widgets/components/filter_bar.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart';
import 'package:superport_v2/widgets/components/superport_date_picker.dart'; import 'package:superport_v2/widgets/components/superport_date_picker.dart';
/// 보고서 다운로드 화면 루트 위젯.
class ReportingPage extends StatefulWidget { class ReportingPage extends StatefulWidget {
const ReportingPage({super.key}); const ReportingPage({super.key});
@@ -20,6 +21,7 @@ class ReportingPage extends StatefulWidget {
State<ReportingPage> createState() => _ReportingPageState(); State<ReportingPage> createState() => _ReportingPageState();
} }
/// 보고서 페이지 UI 상태와 필터 조건을 관리하는 상태 클래스.
class _ReportingPageState extends State<ReportingPage> { class _ReportingPageState extends State<ReportingPage> {
late final WarehouseRepository _warehouseRepository; late final WarehouseRepository _warehouseRepository;
final intl.DateFormat _dateFormat = intl.DateFormat('yyyy.MM.dd'); final intl.DateFormat _dateFormat = intl.DateFormat('yyyy.MM.dd');
@@ -99,6 +101,7 @@ class _ReportingPageState extends State<ReportingPage> {
} }
} }
/// 적용된 필터를 초기 상태로 되돌린다.
void _resetFilters() { void _resetFilters() {
setState(() { setState(() {
_appliedDateRange = null; _appliedDateRange = null;
@@ -112,6 +115,7 @@ class _ReportingPageState extends State<ReportingPage> {
}); });
} }
/// 대기 중인 필터 값을 실제 적용 상태로 확정한다.
void _applyFilters() { void _applyFilters() {
setState(() { setState(() {
_appliedDateRange = _pendingDateRange; _appliedDateRange = _pendingDateRange;

View File

@@ -6,6 +6,7 @@ import '../../../../../widgets/app_layout.dart';
import '../models/postal_search_result.dart'; import '../models/postal_search_result.dart';
import '../widgets/postal_search_dialog.dart'; import '../widgets/postal_search_dialog.dart';
/// 우편번호 검색 모달을 미리보기하는 데모 페이지.
class PostalSearchPage extends StatefulWidget { class PostalSearchPage extends StatefulWidget {
const PostalSearchPage({super.key}); const PostalSearchPage({super.key});
@@ -13,6 +14,7 @@ class PostalSearchPage extends StatefulWidget {
State<PostalSearchPage> createState() => _PostalSearchPageState(); State<PostalSearchPage> createState() => _PostalSearchPageState();
} }
/// 우편번호 검색 결과와 모달 상호작용을 관리한다.
class _PostalSearchPageState extends State<PostalSearchPage> { class _PostalSearchPageState extends State<PostalSearchPage> {
PostalSearchResult? _lastSelection; PostalSearchResult? _lastSelection;

View File

@@ -9,6 +9,7 @@ import 'core/theme/theme_controller.dart';
import 'injection_container.dart'; import 'injection_container.dart';
import 'core/permissions/permission_manager.dart'; import 'core/permissions/permission_manager.dart';
/// Superport 애플리케이션 진입점. 환경 초기화 후 앱 위젯을 실행한다.
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await Environment.initialize(); await Environment.initialize();
@@ -16,6 +17,7 @@ Future<void> main() async {
runApp(const SuperportApp()); runApp(const SuperportApp());
} }
/// 전체 앱을 구성하는 루트 위젯.
class SuperportApp extends StatefulWidget { class SuperportApp extends StatefulWidget {
const SuperportApp({super.key}); const SuperportApp({super.key});
@@ -23,6 +25,7 @@ class SuperportApp extends StatefulWidget {
State<SuperportApp> createState() => _SuperportAppState(); State<SuperportApp> createState() => _SuperportAppState();
} }
/// 테마/권한 스코프를 초기화하고 라우터를 구성한다.
class _SuperportAppState extends State<SuperportApp> { class _SuperportAppState extends State<SuperportApp> {
late final ThemeController _themeController; late final ThemeController _themeController;
late final PermissionManager _permissionManager; late final PermissionManager _permissionManager;

View File

@@ -6,6 +6,7 @@ import '../core/constants/app_sections.dart';
import '../core/theme/theme_controller.dart'; import '../core/theme/theme_controller.dart';
import '../core/permissions/permission_manager.dart'; import '../core/permissions/permission_manager.dart';
/// 앱 기본 레이아웃을 제공하는 셸 위젯. 사이드 네비게이션과 AppBar를 구성한다.
class AppShell extends StatelessWidget { class AppShell extends StatelessWidget {
const AppShell({ const AppShell({
super.key, super.key,
@@ -248,10 +249,17 @@ class _ThemeMenuButton extends StatelessWidget {
(value) => PopupMenuItem<ThemeMode>( (value) => PopupMenuItem<ThemeMode>(
value: value, value: value,
child: Row( child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(_icon(value), size: 18), Icon(_icon(value), size: 18),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(_label(value)), Flexible(
child: Text(
_label(value),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
], ],
), ),
), ),
@@ -265,10 +273,18 @@ class _ThemeMenuButton extends StatelessWidget {
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(icon, size: 18), Icon(icon, size: 18),
const SizedBox(width: 8), const SizedBox(width: 8),
Text('테마 · $label', style: theme.textTheme.labelSmall), Flexible(
child: Text(
'테마 · $label',
style: theme.textTheme.labelSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
], ],
), ),
), ),

View File

@@ -5,22 +5,27 @@ import 'package:shadcn_ui/shadcn_ui.dart';
class SuperportToast { class SuperportToast {
SuperportToast._(); SuperportToast._();
/// 성공 처리 완료를 사용자에게 안내한다.
static void success(BuildContext context, String message) { static void success(BuildContext context, String message) {
_show(context, message, _ToastVariant.success); _show(context, message, _ToastVariant.success);
} }
/// 정보성 피드백을 노출한다.
static void info(BuildContext context, String message) { static void info(BuildContext context, String message) {
_show(context, message, _ToastVariant.info); _show(context, message, _ToastVariant.info);
} }
/// 주의가 필요한 상황을 경고한다.
static void warning(BuildContext context, String message) { static void warning(BuildContext context, String message) {
_show(context, message, _ToastVariant.warning); _show(context, message, _ToastVariant.warning);
} }
/// 오류 발생 시 스낵바를 표시한다.
static void error(BuildContext context, String message) { static void error(BuildContext context, String message) {
_show(context, message, _ToastVariant.error); _show(context, message, _ToastVariant.error);
} }
/// 공통 스낵바 렌더링 로직.
static void _show( static void _show(
BuildContext context, BuildContext context,
String message, String message,
@@ -106,9 +111,13 @@ class SuperportSkeletonList extends StatelessWidget {
this.padding = const EdgeInsets.all(16), this.padding = const EdgeInsets.all(16),
}); });
/// 렌더링할 스켈레톤 행 개수.
final int itemCount; final int itemCount;
/// 각 항목 높이.
final double height; final double height;
/// 행 사이 간격.
final double gap; final double gap;
/// 전체 패딩.
final EdgeInsetsGeometry padding; final EdgeInsetsGeometry padding;
@override @override

View File

@@ -157,8 +157,13 @@ class FilterBarActionConfig {
final Key? applyKey; final Key? applyKey;
final Key? resetKey; final Key? resetKey;
/// 즉시 적용 가능한지 여부.
bool get canApply => applyEnabled ?? hasPendingChanges; bool get canApply => applyEnabled ?? hasPendingChanges;
/// Reset 버튼을 노출할지 여부.
bool get shouldShowReset => bool get shouldShowReset =>
showReset ?? (hasActiveFilters || hasPendingChanges); showReset ?? (hasActiveFilters || hasPendingChanges);
/// Reset 버튼이 활성화 가능한지 여부.
bool get canReset => resetEnabled ?? shouldShowReset; bool get canReset => resetEnabled ?? shouldShowReset;
} }

View File

@@ -17,12 +17,19 @@ class SuperportFormField extends StatelessWidget {
this.spacing = _kFieldSpacing, this.spacing = _kFieldSpacing,
}); });
/// 폼 필드 라벨 텍스트.
final String label; final String label;
/// 입력 영역으로 렌더링할 위젯.
final Widget child; final Widget child;
/// 필수 여부. true면 라벨 옆에 `*` 표시를 추가한다.
final bool required; final bool required;
/// 보조 설명 문구. 에러가 없을 때만 출력된다.
final String? caption; final String? caption;
/// 에러 메시지. 존재하면 캡션 대신 우선적으로 노출된다.
final String? errorText; final String? errorText;
/// 라벨 우측에 배치할 추가 위젯(예: 도움말 버튼).
final Widget? trailing; final Widget? trailing;
/// 라벨과 본문 사이 간격.
final double spacing; final double spacing;
@override @override
@@ -81,14 +88,23 @@ class SuperportTextInput extends StatelessWidget {
}); });
final TextEditingController? controller; final TextEditingController? controller;
/// 입력 없을 때 보여줄 플레이스홀더 위젯.
final Widget? placeholder; final Widget? placeholder;
/// 입력 변경 콜백.
final ValueChanged<String>? onChanged; final ValueChanged<String>? onChanged;
/// 제출(Enter) 시 호출되는 콜백.
final ValueChanged<String>? onSubmitted; final ValueChanged<String>? onSubmitted;
/// 키보드 타입. 숫자/이메일 등으로 지정 가능.
final TextInputType? keyboardType; final TextInputType? keyboardType;
/// 입력 활성 여부.
final bool enabled; final bool enabled;
/// 읽기 전용 여부. true면 수정 불가.
final bool readOnly; final bool readOnly;
/// 최대 줄 수. 1보다 크면 멀티라인 입력을 지원한다.
final int maxLines; final int maxLines;
/// 앞에 붙일 위젯 (아이콘 등).
final Widget? leading; final Widget? leading;
/// 뒤에 붙일 위젯 (버튼 등).
final Widget? trailing; final Widget? trailing;
@override @override
@@ -118,9 +134,13 @@ class SuperportSwitchField extends StatelessWidget {
this.caption, this.caption,
}); });
/// 스위치 현재 상태.
final bool value; final bool value;
/// 상태 변경 시 호출되는 콜백.
final ValueChanged<bool> onChanged; final ValueChanged<bool> onChanged;
/// 스위치 상단에 표시할 제목.
final String? label; final String? label;
/// 보조 설명 문구.
final String? caption; final String? caption;
@override @override

View File

@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
/// 다이얼로그에서 ESC/Enter 키를 처리하고 포커스를 트랩하는 래퍼 위젯.
class DialogKeyboardShortcuts extends StatefulWidget { class DialogKeyboardShortcuts extends StatefulWidget {
const DialogKeyboardShortcuts({ const DialogKeyboardShortcuts({
super.key, super.key,

View File

@@ -1,10 +1,14 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
/// 데스크톱 레이아웃으로 간주할 최소 너비(px).
const double desktopBreakpoint = 1200; const double desktopBreakpoint = 1200;
/// 태블릿 레이아웃을 구분하는 최소 너비(px).
const double tabletBreakpoint = 960; const double tabletBreakpoint = 960;
/// 뷰포트 크기별 분기값.
enum DeviceBreakpoint { mobile, tablet, desktop } enum DeviceBreakpoint { mobile, tablet, desktop }
/// 현재 화면 너비에 맞는 [DeviceBreakpoint]를 계산한다.
DeviceBreakpoint breakpointForWidth(double width) { DeviceBreakpoint breakpointForWidth(double width) {
if (width >= desktopBreakpoint) { if (width >= desktopBreakpoint) {
return DeviceBreakpoint.desktop; return DeviceBreakpoint.desktop;
@@ -15,34 +19,52 @@ DeviceBreakpoint breakpointForWidth(double width) {
return DeviceBreakpoint.mobile; return DeviceBreakpoint.mobile;
} }
/// 주어진 너비가 데스크톱 분기에 해당하는지 여부.
bool isDesktop(double width) => width >= desktopBreakpoint; bool isDesktop(double width) => width >= desktopBreakpoint;
/// 주어진 너비가 태블릿 분기에 해당하는지 여부.
bool isTablet(double width) => bool isTablet(double width) =>
width >= tabletBreakpoint && width < desktopBreakpoint; width >= tabletBreakpoint && width < desktopBreakpoint;
/// 주어진 너비가 모바일 분기에 해당하는지 여부.
bool isMobile(double width) => width < tabletBreakpoint; bool isMobile(double width) => width < tabletBreakpoint;
/// 컨텍스트 기반으로 데스크톱 범위인지 확인한다.
bool isDesktopContext(BuildContext context) => bool isDesktopContext(BuildContext context) =>
isDesktop(MediaQuery.of(context).size.width); isDesktop(MediaQuery.of(context).size.width);
/// 컨텍스트 기반으로 태블릿 범위인지 확인한다.
bool isTabletContext(BuildContext context) => bool isTabletContext(BuildContext context) =>
isTablet(MediaQuery.of(context).size.width); isTablet(MediaQuery.of(context).size.width);
/// 컨텍스트 기반으로 모바일 범위인지 확인한다.
bool isMobileContext(BuildContext context) => bool isMobileContext(BuildContext context) =>
isMobile(MediaQuery.of(context).size.width); isMobile(MediaQuery.of(context).size.width);
/// 반응형 분기 정보를 담는 값 객체.
class ResponsiveBreakpoints { class ResponsiveBreakpoints {
ResponsiveBreakpoints._(this.width) : breakpoint = breakpointForWidth(width); ResponsiveBreakpoints._(this.width) : breakpoint = breakpointForWidth(width);
/// 현재 뷰 가로 너비.
final double width; final double width;
/// 너비에서 계산된 분기값.
final DeviceBreakpoint breakpoint; final DeviceBreakpoint breakpoint;
/// 모바일 범위인지 여부.
bool get isMobile => breakpoint == DeviceBreakpoint.mobile; bool get isMobile => breakpoint == DeviceBreakpoint.mobile;
/// 태블릿 범위인지 여부.
bool get isTablet => breakpoint == DeviceBreakpoint.tablet; bool get isTablet => breakpoint == DeviceBreakpoint.tablet;
/// 데스크톱 범위인지 여부.
bool get isDesktop => breakpoint == DeviceBreakpoint.desktop; bool get isDesktop => breakpoint == DeviceBreakpoint.desktop;
/// 현재 컨텍스트에서 [ResponsiveBreakpoints]를 생성한다.
static ResponsiveBreakpoints of(BuildContext context) { static ResponsiveBreakpoints of(BuildContext context) {
final size = MediaQuery.of(context).size; final size = MediaQuery.of(context).size;
return ResponsiveBreakpoints._(size.width); return ResponsiveBreakpoints._(size.width);
} }
} }
/// 기기 타입에 따라 위젯 빌더를 분기 실행하는 레이아웃 헬퍼.
class ResponsiveLayoutBuilder extends StatelessWidget { class ResponsiveLayoutBuilder extends StatelessWidget {
const ResponsiveLayoutBuilder({ const ResponsiveLayoutBuilder({
super.key, super.key,
@@ -51,8 +73,11 @@ class ResponsiveLayoutBuilder extends StatelessWidget {
required this.desktop, required this.desktop,
}); });
/// 모바일 뷰에서 사용할 빌더.
final WidgetBuilder mobile; final WidgetBuilder mobile;
/// 태블릿 뷰에서 사용할 빌더. 제공되지 않으면 데스크톱 빌더를 재사용한다.
final WidgetBuilder? tablet; final WidgetBuilder? tablet;
/// 데스크톱 뷰에서 사용할 빌더.
final WidgetBuilder desktop; final WidgetBuilder desktop;
@override @override
@@ -74,6 +99,7 @@ class ResponsiveLayoutBuilder extends StatelessWidget {
} }
} }
/// 특정 분기에서만 child를 표시하는 헬퍼 위젯.
class ResponsiveVisibility extends StatelessWidget { class ResponsiveVisibility extends StatelessWidget {
const ResponsiveVisibility({ const ResponsiveVisibility({
super.key, super.key,
@@ -86,8 +112,11 @@ class ResponsiveVisibility extends StatelessWidget {
}, },
}); });
/// 조건을 만족할 때 보여줄 실제 위젯.
final Widget child; final Widget child;
/// 조건을 만족하지 않을 때 대체로 렌더링할 위젯.
final Widget replacement; final Widget replacement;
/// 어떤 분기에서 child를 노출할지 정의한 집합.
final Set<DeviceBreakpoint> visibleOn; final Set<DeviceBreakpoint> visibleOn;
@override @override

View File

@@ -62,6 +62,7 @@ class SuperportDialog extends StatelessWidget {
final FutureOr<void> Function()? onSubmit; final FutureOr<void> Function()? onSubmit;
final bool enableFocusTrap; final bool enableFocusTrap;
/// 공통 다이얼로그를 노출하는 헬퍼. `showDialog`와 동일하게 동작한다.
static Future<T?> show<T>({ static Future<T?> show<T>({
required BuildContext context, required BuildContext context,
required SuperportDialog dialog, required SuperportDialog dialog,
@@ -285,7 +286,7 @@ class _SuperportDialogHeader extends StatelessWidget {
} }
} }
/// Convenience wrapper around [SuperportDialog.show] to reduce boilerplate in pages. /// 페이지에서 반복되는 호출 패턴을 줄이기 위한 편의 함수.
Future<T?> showSuperportDialog<T>({ Future<T?> showSuperportDialog<T>({
required BuildContext context, required BuildContext context,
required String title, required String title,

View File

@@ -11,7 +11,10 @@ class SuperportTableSortState {
required this.ascending, required this.ascending,
}); });
/// 정렬 대상이 되는 컬럼 인덱스.
final int columnIndex; final int columnIndex;
/// 오름차순 여부. `false`면 내림차순이다.
final bool ascending; final bool ascending;
} }
@@ -25,10 +28,19 @@ class SuperportTablePagination {
this.pageSizeOptions = const <int>[10, 20, 50], this.pageSizeOptions = const <int>[10, 20, 50],
}); });
/// 현재 페이지 번호(1-base).
final int currentPage; final int currentPage;
/// 전체 페이지 수.
final int totalPages; final int totalPages;
/// 전체 데이터 건수.
final int totalItems; final int totalItems;
/// 현재 페이지네이션에서 선택된 페이지 크기.
final int pageSize; final int pageSize;
/// 사용자에게 노출할 페이지 크기 옵션 목록.
final List<int> pageSizeOptions; final List<int> pageSizeOptions;
} }
@@ -55,6 +67,7 @@ class SuperportTable extends StatelessWidget {
_headerCells = null, _headerCells = null,
_rowCells = null; _rowCells = null;
/// 헤더와 행을 [ShadTableCell] 단위로 직접 전달할 때 사용하는 생성자.
const SuperportTable.fromCells({ const SuperportTable.fromCells({
super.key, super.key,
required List<ShadTableCell> header, required List<ShadTableCell> header,

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:shadcn_ui/shadcn_ui.dart';
/// 기능 플래그 비활성화 시 사양 정보를 보여주기 위한 테이블 정의.
class SpecTable { class SpecTable {
const SpecTable({ const SpecTable({
required this.columns, required this.columns,
@@ -13,6 +14,7 @@ class SpecTable {
final double? columnWidth; final double? columnWidth;
} }
/// 사양 페이지의 섹션 정보를 표현한다.
class SpecSection { class SpecSection {
const SpecSection({ const SpecSection({
required this.title, required this.title,
@@ -27,6 +29,7 @@ class SpecSection {
final SpecTable? table; final SpecTable? table;
} }
/// 기능 비활성 시 화면 대신 노출하는 사양 설명 페이지.
class SpecPage extends StatelessWidget { class SpecPage extends StatelessWidget {
const SpecPage({ const SpecPage({
super.key, super.key,
@@ -139,6 +142,7 @@ class SpecPage extends StatelessWidget {
} }
} }
/// 사양 테이블을 렌더링하는 내부 위젯.
class _SpecTableView extends StatelessWidget { class _SpecTableView extends StatelessWidget {
const _SpecTableView({required this.table}); const _SpecTableView({required this.table});

View File

@@ -8,15 +8,20 @@ import 'package:superport_v2/features/approvals/domain/repositories/approval_rep
import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart'; import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart';
import 'package:superport_v2/features/approvals/presentation/controllers/approval_controller.dart'; import 'package:superport_v2/features/approvals/presentation/controllers/approval_controller.dart';
/// ApprovalRepository 모킹 클래스.
class _MockApprovalRepository extends Mock implements ApprovalRepository {} class _MockApprovalRepository extends Mock implements ApprovalRepository {}
/// Approval 생성 요청을 대체하기 위한 가짜 입력.
class _FakeApprovalInput extends Fake implements ApprovalInput {} class _FakeApprovalInput extends Fake implements ApprovalInput {}
/// 단계 행위 요청을 대체하기 위한 가짜 입력.
class _FakeStepActionInput extends Fake implements ApprovalStepActionInput {} class _FakeStepActionInput extends Fake implements ApprovalStepActionInput {}
/// ApprovalTemplateRepository 모킹 클래스.
class _MockApprovalTemplateRepository extends Mock class _MockApprovalTemplateRepository extends Mock
implements ApprovalTemplateRepository {} implements ApprovalTemplateRepository {}
/// 템플릿 단계 할당 요청을 대체하기 위한 가짜 입력.
class _FakeStepAssignmentInput extends Fake class _FakeStepAssignmentInput extends Fake
implements ApprovalStepAssignmentInput {} implements ApprovalStepAssignmentInput {}
@@ -46,6 +51,7 @@ void main() {
histories: const [], histories: const [],
); );
/// 테스트용 페이징 응답을 생성하는 헬퍼.
PaginatedResult<Approval> createResult(List<Approval> items) { PaginatedResult<Approval> createResult(List<Approval> items) {
return PaginatedResult<Approval>( return PaginatedResult<Approval>(
items: items, items: items,
@@ -70,6 +76,7 @@ void main() {
); );
}); });
// fetch 메서드 관련 시나리오
group('fetch', () { group('fetch', () {
setUp(() { setUp(() {
when( when(
@@ -86,6 +93,7 @@ void main() {
).thenAnswer((_) async => createResult([sampleApproval])); ).thenAnswer((_) async => createResult([sampleApproval]));
}); });
// 정상적으로 결재 목록을 조회한다.
test('목록을 조회한다', () async { test('목록을 조회한다', () async {
await controller.fetch(); await controller.fetch();
@@ -93,6 +101,7 @@ void main() {
expect(controller.errorMessage, isNull); expect(controller.errorMessage, isNull);
}); });
// 검색어/상태/기간 필터가 Repository 호출에 반영되는지 확인한다.
test('필터 전달을 검증한다', () async { test('필터 전달을 검증한다', () async {
controller.updateQuery('TRX'); controller.updateQuery('TRX');
controller.updateStatusFilter(ApprovalStatusFilter.approved); controller.updateStatusFilter(ApprovalStatusFilter.approved);
@@ -116,6 +125,7 @@ void main() {
).called(1); ).called(1);
}); });
// Repository 오류 발생 시 errorMessage가 설정된다.
test('에러 발생 시 errorMessage 설정', () async { test('에러 발생 시 errorMessage 설정', () async {
when( when(
() => repository.list( () => repository.list(