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

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 참고
## Core/Widgets 주석화
세부 계획은 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_error.dart` | 에러 모델/매퍼 설명 |
| 1 | `lib/core/network/interceptors/auth_interceptor.dart` | 토큰 갱신 흐름, 재시도 로직 |
| 1 | `lib/core/permissions/permission_manager.dart` | 권한 검사/Scope 동작 |
| 2 | `lib/core/config/environment.dart` | env 초기화 흐름, 추가 설명 필요시 |
| 2 | `lib/core/theme/superport_shad_theme.dart` | 테마 팩토리 설명 |
| 2 | `lib/core/routing/app_router.dart` | 라우터 설정 요약 |
| 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`
2. `core/theme`, `core/routing`, `core/config`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,11 @@ import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../entities/approval.dart';
/// 결재 도메인에서 사용하는 저장소 인터페이스.
///
/// - presentation 레이어는 이 인터페이스만 의존하며, 실제 구현은 data 레이어가 담당한다.
abstract class ApprovalRepository {
/// 결재 목록을 조회한다. 필터/페이지 조건을 지원한다.
Future<PaginatedResult<Approval>> list({
int page = 1,
int pageSize = 20,
@@ -14,6 +18,7 @@ abstract class ApprovalRepository {
bool includeSteps = false,
});
/// 결재 상세 정보를 조회한다.
Future<Approval> fetchDetail(
int id, {
bool includeSteps = true,
@@ -29,11 +34,15 @@ abstract class ApprovalRepository {
/// 결재 단계 일괄 생성/재배치
Future<Approval> assignSteps(ApprovalStepAssignmentInput input);
/// 결재를 생성한다.
Future<Approval> create(ApprovalInput input);
/// 결재를 수정한다.
Future<Approval> update(int id, ApprovalInput input);
/// 결재를 삭제한다.
Future<void> delete(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';
/// 결재 템플릿 도메인 저장소 인터페이스.
abstract class ApprovalTemplateRepository {
/// 템플릿 목록을 조회한다.
Future<PaginatedResult<ApprovalTemplate>> list({
int page = 1,
int pageSize = 20,
@@ -10,20 +12,25 @@ abstract class ApprovalTemplateRepository {
bool? isActive,
});
/// 템플릿 상세를 조회한다.
Future<ApprovalTemplate> fetchDetail(int id, {bool includeSteps = true});
/// 템플릿을 생성한다. 단계 입력은 옵션이다.
Future<ApprovalTemplate> create(
ApprovalTemplateInput input, {
List<ApprovalTemplateStepInput> steps = const [],
});
/// 템플릿 기본 정보와 단계 구성을 수정한다.
Future<ApprovalTemplate> update(
int id,
ApprovalTemplateInput input, {
List<ApprovalTemplateStepInput>? steps,
});
/// 템플릿을 삭제한다.
Future<void> delete(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';
/// 결재 이력(History) API 응답을 표현하는 DTO.
class ApprovalHistoryRecordDto {
ApprovalHistoryRecordDto({
required this.id,
@@ -30,6 +31,7 @@ class ApprovalHistoryRecordDto {
final DateTime actionAt;
final String? note;
/// 원본 JSON에서 필드를 파싱해 DTO를 생성한다.
factory ApprovalHistoryRecordDto.fromJson(Map<String, dynamic> json) {
final approvalData = json['approval'] as Map<String, dynamic>?;
final id = json['id'] as int? ?? 0;
@@ -74,6 +76,7 @@ class ApprovalHistoryRecordDto {
);
}
/// DTO를 도메인 [ApprovalHistoryRecord] 엔티티로 변환한다.
ApprovalHistoryRecord toEntity() {
return ApprovalHistoryRecord(
id: id,
@@ -89,6 +92,7 @@ class ApprovalHistoryRecordDto {
);
}
/// 페이징 응답을 읽어 [PaginatedResult] 형태로 변환한다.
static PaginatedResult<ApprovalHistoryRecord> parsePaginated(
Map<String, dynamic>? json,
) {
@@ -107,6 +111,7 @@ class ApprovalHistoryRecordDto {
}
}
/// 다양한 형식으로 전달될 수 있는 날짜 값을 파싱한다.
DateTime? _parseDate(Object? value) {
if (value == null) return null;
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 '../dtos/approval_history_record_dto.dart';
/// 결재 이력 API를 호출하는 원격 저장소 구현체.
class ApprovalHistoryRepositoryRemote implements ApprovalHistoryRepository {
ApprovalHistoryRepositoryRemote({required ApiClient apiClient})
: _api = apiClient;
@@ -14,6 +15,7 @@ class ApprovalHistoryRepositoryRemote implements ApprovalHistoryRepository {
static const _basePath = '/approval-histories';
/// 결재 이력 목록을 조회한다.
@override
Future<PaginatedResult<ApprovalHistoryRecord>> list({
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';
/// 결재 이력 데이터를 조회하는 도메인 저장소 인터페이스.
abstract class ApprovalHistoryRepository {
/// 이력 목록을 조회한다.
Future<PaginatedResult<ApprovalHistoryRecord>> list({
int page = 1,
int pageSize = 20,

View File

@@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart';
import '../../../presentation/pages/approval_page.dart';
/// 결재 요청 탭에서 사용하는 래퍼 페이지. 실 구현은 [ApprovalPage]를 재사용한다.
class ApprovalRequestPage extends StatelessWidget {
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';
/// 결재 단계 기록 API 응답을 표현하는 DTO.
class ApprovalStepRecordDto {
ApprovalStepRecordDto({
required this.approvalId,
@@ -20,6 +21,7 @@ class ApprovalStepRecordDto {
final String? templateName;
final ApprovalStep step;
/// JSON에서 필요한 필드를 추출해 DTO를 생성한다.
factory ApprovalStepRecordDto.fromJson(Map<String, dynamic> json) {
final approvalData = json['approval'] as Map<String, dynamic>?;
final approvalId =
@@ -49,6 +51,7 @@ class ApprovalStepRecordDto {
);
}
/// DTO를 [ApprovalStepRecord] 엔티티로 변환한다.
ApprovalStepRecord toEntity() {
return ApprovalStepRecord(
approvalId: approvalId,
@@ -59,6 +62,7 @@ class ApprovalStepRecordDto {
);
}
/// 페이징 응답을 [PaginatedResult] 형태로 반환한다.
static PaginatedResult<ApprovalStepRecord> parsePaginated(
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 '../../domain/entities/approval_step_input.dart';
/// 결재 단계 API를 호출하는 원격 저장소 구현체.
class ApprovalStepRepositoryRemote implements ApprovalStepRepository {
ApprovalStepRepositoryRemote({required ApiClient apiClient})
: _api = apiClient;
@@ -15,6 +16,7 @@ class ApprovalStepRepositoryRemote implements ApprovalStepRepository {
static const _basePath = '/approval-steps';
/// 결재 단계 목록을 조회한다.
@override
Future<PaginatedResult<ApprovalStepRecord>> list({
int page = 1,
@@ -40,6 +42,7 @@ class ApprovalStepRepositoryRemote implements ApprovalStepRepository {
return ApprovalStepRecordDto.parsePaginated(response.data);
}
/// 단일 결재 단계 상세를 조회한다.
@override
Future<ApprovalStepRecord> fetchDetail(int id) async {
final response = await _api.get<Map<String, dynamic>>(
@@ -50,6 +53,7 @@ class ApprovalStepRepositoryRemote implements ApprovalStepRepository {
return ApprovalStepRecordDto.fromJson(data).toEntity();
}
/// 결재 단계를 생성한다.
@override
Future<ApprovalStepRecord> create(ApprovalStepInput input) async {
final response = await _api.post<Map<String, dynamic>>(
@@ -64,6 +68,7 @@ class ApprovalStepRepositoryRemote implements ApprovalStepRepository {
return ApprovalStepRecordDto.fromJson(data).toEntity();
}
/// 결재 단계를 수정한다.
@override
Future<ApprovalStepRecord> update(int id, ApprovalStepInput input) async {
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_record.dart';
/// 결재 단계 목록/상세를 다루는 도메인 저장소 인터페이스.
abstract class ApprovalStepRepository {
/// 결재 단계 목록을 조회한다.
Future<PaginatedResult<ApprovalStepRecord>> list({
int page = 1,
int pageSize = 20,
@@ -13,6 +15,7 @@ abstract class ApprovalStepRepository {
int? approvalId,
});
/// 결재 단계 상세를 조회한다.
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/repositories/approval_step_repository.dart';
/// 결재 단계 관리 진입 페이지. 기능 플래그에 따라 실제 화면 또는 준비중 화면을 노출한다.
class ApprovalStepPage extends StatelessWidget {
const ApprovalStepPage({super.key});
@@ -50,6 +51,7 @@ class ApprovalStepPage extends StatelessWidget {
}
}
/// 결재 단계 기능이 활성화된 경우 사용하는 실제 화면 위젯.
class _ApprovalStepEnabledPage extends StatefulWidget {
const _ApprovalStepEnabledPage();
@@ -58,6 +60,7 @@ class _ApprovalStepEnabledPage extends StatefulWidget {
_ApprovalStepEnabledPageState();
}
/// 결재 단계 목록과 필터 상태를 관리하는 상태 클래스.
class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
late final ApprovalStepController _controller;
final TextEditingController _searchController = TextEditingController();

View File

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

View File

@@ -4,6 +4,7 @@ import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../core/constants/app_sections.dart';
/// Superport 로그인 화면. 간단한 유효성 검증 후 대시보드로 이동한다.
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@@ -11,6 +12,7 @@ class LoginPage extends StatefulWidget {
State<LoginPage> createState() => _LoginPageState();
}
/// 로그인 폼의 상태를 관리한다.
class _LoginPageState extends State<LoginPage> {
final idController = 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';
/// 고객(Customer) API 응답을 다루는 DTO.
class CustomerDto {
CustomerDto({
this.id,
@@ -36,6 +37,7 @@ class CustomerDto {
final DateTime? createdAt;
final DateTime? updatedAt;
/// 원본 JSON으로부터 DTO를 생성한다.
factory CustomerDto.fromJson(Map<String, dynamic> json) {
return CustomerDto(
id: json['id'] as int?,
@@ -57,6 +59,7 @@ class CustomerDto {
);
}
/// DTO를 JSON 맵으로 변환한다.
Map<String, dynamic> toJson() {
return {
if (id != null) 'id': id,
@@ -76,6 +79,7 @@ class CustomerDto {
};
}
/// DTO를 도메인 [Customer] 엔티티로 변환한다.
Customer toEntity() => Customer(
id: id,
customerCode: customerCode,
@@ -93,6 +97,7 @@ class CustomerDto {
updatedAt: updatedAt,
);
/// 페이징 응답을 파싱해 [PaginatedResult] 형식으로 반환한다.
static PaginatedResult<Customer> parsePaginated(Map<String, dynamic>? json) {
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
final items = rawItems
@@ -108,6 +113,7 @@ class CustomerDto {
}
}
/// 고객 주소의 우편번호 정보를 담는 DTO.
class CustomerZipcodeDto {
CustomerZipcodeDto({
required this.zipcode,
@@ -121,6 +127,7 @@ class CustomerZipcodeDto {
final String? sigungu;
final String? roadName;
/// JSON에서 우편번호 정보를 파싱한다.
factory CustomerZipcodeDto.fromJson(Map<String, dynamic> json) {
return CustomerZipcodeDto(
zipcode: json['zipcode'] as String,
@@ -130,6 +137,7 @@ class CustomerZipcodeDto {
);
}
/// DTO를 JSON 맵으로 직렬화한다.
Map<String, dynamic> toJson() {
return {
'zipcode': zipcode,
@@ -139,6 +147,7 @@ class CustomerZipcodeDto {
};
}
/// DTO를 [CustomerZipcode] 엔티티로 변환한다.
CustomerZipcode toEntity() => CustomerZipcode(
zipcode: zipcode,
sido: sido,
@@ -147,6 +156,7 @@ class CustomerZipcodeDto {
);
}
/// 문자열/DateTime 값을 파싱해 [DateTime]으로 변환한다.
DateTime? _parseDate(Object? value) {
if (value == null) return null;
if (value is DateTime) return value;
@@ -154,6 +164,7 @@ DateTime? _parseDate(Object? value) {
return null;
}
/// 고객 입력 모델을 API 요청 바디로 변환한다.
Map<String, dynamic> customerInputToJson(CustomerInput input) {
final map = input.toPayload();
map.removeWhere((key, value) => value == null);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import '../../domain/entities/group.dart';
import '../../domain/repositories/group_repository.dart';
import '../dtos/group_dto.dart';
/// 권한 그룹 API를 호출하는 원격 저장소 구현체.
class GroupRepositoryRemote implements GroupRepository {
GroupRepositoryRemote({required ApiClient apiClient}) : _api = apiClient;
@@ -13,6 +14,7 @@ class GroupRepositoryRemote implements GroupRepository {
static const _basePath = '/groups';
/// 그룹 목록을 조회한다.
@override
Future<PaginatedResult<Group>> list({
int page = 1,
@@ -35,6 +37,7 @@ class GroupRepositoryRemote implements GroupRepository {
return GroupDto.parsePaginated(response.data ?? const {});
}
/// 새 그룹을 생성한다.
@override
Future<Group> create(GroupInput input) async {
final response = await _api.post<Map<String, dynamic>>(
@@ -46,6 +49,7 @@ class GroupRepositoryRemote implements GroupRepository {
return GroupDto.fromJson(data).toEntity();
}
/// 그룹 정보를 수정한다.
@override
Future<Group> update(int id, GroupInput input) async {
final response = await _api.patch<Map<String, dynamic>>(
@@ -57,11 +61,13 @@ class GroupRepositoryRemote implements GroupRepository {
return GroupDto.fromJson(data).toEntity();
}
/// 그룹을 삭제한다.
@override
Future<void> delete(int id) async {
await _api.delete<void>('$_basePath/$id');
}
/// 삭제된 그룹을 복구한다.
@override
Future<Group> restore(int id) async {
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/repositories/group_repository.dart';
/// 기본 그룹 여부 필터.
enum GroupDefaultFilter { all, defaultOnly, nonDefault }
/// 그룹 사용 상태 필터.
enum GroupStatusFilter { all, activeOnly, inactiveOnly }
/// 그룹 마스터 화면 상태 컨트롤러
@@ -34,6 +36,7 @@ class GroupController extends ChangeNotifier {
GroupStatusFilter get statusFilter => _statusFilter;
String? get errorMessage => _errorMessage;
/// 그룹 목록을 조회한다.
Future<void> fetch({int page = 1}) async {
_isLoading = true;
_errorMessage = null;
@@ -65,21 +68,25 @@ class GroupController extends ChangeNotifier {
}
}
/// 검색어를 변경한다.
void updateQuery(String value) {
_query = value;
notifyListeners();
}
/// 기본 그룹 여부 필터를 변경한다.
void updateDefaultFilter(GroupDefaultFilter filter) {
_defaultFilter = filter;
notifyListeners();
}
/// 사용 여부 필터를 변경한다.
void updateStatusFilter(GroupStatusFilter filter) {
_statusFilter = filter;
notifyListeners();
}
/// 새 그룹을 생성한다.
Future<Group?> create(GroupInput input) async {
_setSubmitting(true);
try {
@@ -95,6 +102,7 @@ class GroupController extends ChangeNotifier {
}
}
/// 그룹 정보를 수정한다.
Future<Group?> update(int id, GroupInput input) async {
_setSubmitting(true);
try {
@@ -110,6 +118,7 @@ class GroupController extends ChangeNotifier {
}
}
/// 그룹을 삭제한다.
Future<bool> delete(int id) async {
_setSubmitting(true);
try {
@@ -125,6 +134,7 @@ class GroupController extends ChangeNotifier {
}
}
/// 삭제된 그룹을 복구한다.
Future<Group?> restore(int id) async {
_setSubmitting(true);
try {
@@ -140,11 +150,13 @@ class GroupController extends ChangeNotifier {
}
}
/// 에러 메시지를 초기화한다.
void clearError() {
_errorMessage = null;
notifyListeners();
}
/// 제출 상태 플래그를 갱신하고 리스너에 알린다.
void _setSubmitting(bool value) {
_isSubmitting = value;
notifyListeners();

View File

@@ -13,6 +13,7 @@ import '../../domain/entities/group.dart';
import '../../domain/repositories/group_repository.dart';
import '../controllers/group_controller.dart';
/// 권한 그룹 관리 페이지. 기능 플래그에 따라 사양 화면 또는 실제 목록을 보여준다.
class GroupPage extends StatelessWidget {
const GroupPage({super.key});
@@ -69,6 +70,7 @@ class GroupPage extends StatelessWidget {
}
}
/// 그룹 기능이 활성화된 경우 사용하는 실제 화면 위젯.
class _GroupEnabledPage extends StatefulWidget {
const _GroupEnabledPage();
@@ -76,6 +78,7 @@ class _GroupEnabledPage extends StatefulWidget {
State<_GroupEnabledPage> createState() => _GroupEnabledPageState();
}
/// 그룹 목록과 필터/폼 상태를 관리하는 상태 클래스.
class _GroupEnabledPageState extends State<_GroupEnabledPage> {
late final GroupController _controller;
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';
/// 그룹별 메뉴 권한을 표현하는 DTO.
class GroupPermissionDto {
GroupPermissionDto({
this.id,
@@ -31,6 +32,7 @@ class GroupPermissionDto {
final DateTime? createdAt;
final DateTime? updatedAt;
/// JSON에서 권한 정보를 파싱한다.
factory GroupPermissionDto.fromJson(Map<String, dynamic> json) {
return GroupPermissionDto(
id: json['id'] as int?,
@@ -52,6 +54,7 @@ class GroupPermissionDto {
);
}
/// DTO를 도메인 [GroupPermission] 엔티티로 변환한다.
GroupPermission toEntity() => GroupPermission(
id: id,
group: group.toEntity(),
@@ -67,6 +70,7 @@ class GroupPermissionDto {
updatedAt: updatedAt,
);
/// 페이징 응답을 [PaginatedResult]로 변환한다.
static PaginatedResult<GroupPermission> parsePaginated(
Map<String, dynamic>? json,
) {
@@ -84,12 +88,14 @@ class GroupPermissionDto {
}
}
/// 권한 설정에 포함된 그룹 정보를 담는 DTO.
class GroupPermissionGroupDto {
GroupPermissionGroupDto({required this.id, required this.groupName});
final int id;
final String groupName;
/// JSON에서 그룹 정보를 파싱한다.
factory GroupPermissionGroupDto.fromJson(Map<String, dynamic> json) {
return GroupPermissionGroupDto(
id: json['id'] as int? ?? json['group_id'] as int,
@@ -98,16 +104,19 @@ class GroupPermissionGroupDto {
);
}
/// DTO를 [GroupPermissionGroup] 엔티티로 변환한다.
GroupPermissionGroup toEntity() =>
GroupPermissionGroup(id: id, groupName: groupName);
}
/// 권한 대상 메뉴 정보를 담는 DTO.
class GroupPermissionMenuDto {
GroupPermissionMenuDto({required this.id, required this.menuName});
final int id;
final String menuName;
/// JSON에서 메뉴 정보를 파싱한다.
factory GroupPermissionMenuDto.fromJson(Map<String, dynamic> json) {
return GroupPermissionMenuDto(
id: json['id'] as int? ?? json['menu_id'] as int,
@@ -115,10 +124,12 @@ class GroupPermissionMenuDto {
);
}
/// DTO를 [GroupPermissionMenu] 엔티티로 변환한다.
GroupPermissionMenu toEntity() =>
GroupPermissionMenu(id: id, menuName: menuName);
}
/// 문자열/DateTime 값을 파싱해 [DateTime]으로 변환한다.
DateTime? _parseDate(Object? value) {
if (value == null) return null;
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 '../dtos/group_permission_dto.dart';
/// 그룹-메뉴 권한 API를 호출하는 원격 저장소.
class GroupPermissionRepositoryRemote implements GroupPermissionRepository {
GroupPermissionRepositoryRemote({required ApiClient apiClient})
: _api = apiClient;
@@ -14,6 +15,7 @@ class GroupPermissionRepositoryRemote implements GroupPermissionRepository {
static const _basePath = '/group-menu-permissions';
/// 그룹 권한 목록을 조회한다.
@override
Future<PaginatedResult<GroupPermission>> list({
int page = 1,
@@ -39,6 +41,7 @@ class GroupPermissionRepositoryRemote implements GroupPermissionRepository {
return GroupPermissionDto.parsePaginated(response.data ?? const {});
}
/// 그룹 권한을 생성한다.
@override
Future<GroupPermission> create(GroupPermissionInput input) async {
final response = await _api.post<Map<String, dynamic>>(
@@ -50,6 +53,7 @@ class GroupPermissionRepositoryRemote implements GroupPermissionRepository {
return GroupPermissionDto.fromJson(data).toEntity();
}
/// 그룹 권한을 수정한다.
@override
Future<GroupPermission> update(int id, GroupPermissionInput input) async {
final response = await _api.patch<Map<String, dynamic>>(
@@ -61,11 +65,13 @@ class GroupPermissionRepositoryRemote implements GroupPermissionRepository {
return GroupPermissionDto.fromJson(data).toEntity();
}
/// 그룹 권한을 삭제한다.
@override
Future<void> delete(int id) async {
await _api.delete<void>('$_basePath/$id');
}
/// 삭제된 그룹 권한을 복구한다.
@override
Future<GroupPermission> restore(int id) async {
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';
/// 그룹-메뉴 권한을 다루는 도메인 저장소 인터페이스.
abstract class GroupPermissionRepository {
/// 권한 목록을 조회한다.
Future<PaginatedResult<GroupPermission>> list({
int page = 1,
int pageSize = 20,
@@ -12,11 +14,15 @@ abstract class GroupPermissionRepository {
bool includeDeleted = false,
});
/// 그룹 권한을 생성한다.
Future<GroupPermission> create(GroupPermissionInput input);
/// 그룹 권한을 수정한다.
Future<GroupPermission> update(int id, GroupPermissionInput input);
/// 그룹 권한을 삭제한다.
Future<void> delete(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/repositories/group_permission_repository.dart';
/// 그룹 권한 활성 여부 필터.
enum GroupPermissionStatusFilter { all, activeOnly, inactiveOnly }
/// 그룹-메뉴 권한 화면용 컨트롤러
@@ -53,6 +54,7 @@ class GroupPermissionController extends ChangeNotifier {
List<Group> get groups => List.unmodifiable(_groups);
List<MenuItem> get menus => List.unmodifiable(_menus);
/// 그룹 목록을 로드해 권한 연결 시 선택할 수 있도록 준비한다.
Future<void> loadGroups() async {
_isLoadingGroups = true;
notifyListeners();
@@ -69,6 +71,7 @@ class GroupPermissionController extends ChangeNotifier {
}
}
/// 메뉴 목록을 로드해 권한 연결 시 선택할 수 있도록 준비한다.
Future<void> loadMenus() async {
_isLoadingMenus = true;
notifyListeners();
@@ -89,6 +92,7 @@ class GroupPermissionController extends ChangeNotifier {
}
}
/// 그룹 권한 목록을 조회한다.
Future<void> fetch({int page = 1}) async {
_isLoading = true;
_errorMessage = null;
@@ -116,26 +120,31 @@ class GroupPermissionController extends ChangeNotifier {
}
}
/// 그룹 필터를 변경한다.
void updateGroupFilter(int? groupId) {
_groupFilter = groupId;
notifyListeners();
}
/// 메뉴 필터를 변경한다.
void updateMenuFilter(int? menuId) {
_menuFilter = menuId;
notifyListeners();
}
/// 권한 활성 상태 필터를 변경한다.
void updateStatusFilter(GroupPermissionStatusFilter filter) {
_statusFilter = filter;
notifyListeners();
}
/// 삭제 포함 여부를 변경한다.
void updateIncludeDeleted(bool value) {
_includeDeleted = value;
notifyListeners();
}
/// 그룹 권한을 생성한다.
Future<GroupPermission?> create(GroupPermissionInput input) async {
_setSubmitting(true);
try {
@@ -151,6 +160,7 @@ class GroupPermissionController extends ChangeNotifier {
}
}
/// 그룹 권한을 수정한다.
Future<GroupPermission?> update(int id, GroupPermissionInput input) async {
_setSubmitting(true);
try {
@@ -166,6 +176,7 @@ class GroupPermissionController extends ChangeNotifier {
}
}
/// 그룹 권한을 삭제한다.
Future<bool> delete(int id) async {
_setSubmitting(true);
try {
@@ -181,6 +192,7 @@ class GroupPermissionController extends ChangeNotifier {
}
}
/// 삭제된 그룹 권한을 복구한다.
Future<GroupPermission?> restore(int id) async {
_setSubmitting(true);
try {
@@ -196,11 +208,13 @@ class GroupPermissionController extends ChangeNotifier {
}
}
/// 에러 메시지를 초기화한다.
void clearError() {
_errorMessage = null;
notifyListeners();
}
/// 제출 상태 플래그를 갱신하고 리스너에게 알린다.
void _setSubmitting(bool value) {
_isSubmitting = value;
notifyListeners();

View File

@@ -18,6 +18,7 @@ import '../../domain/entities/group_permission.dart';
import '../../domain/repositories/group_permission_repository.dart';
import '../controllers/group_permission_controller.dart';
/// 그룹-메뉴 권한 설정 페이지. 기능 플래그에 따라 사양/실제 화면을 전환한다.
class GroupPermissionPage extends StatelessWidget {
const GroupPermissionPage({super.key});
@@ -99,6 +100,7 @@ class GroupPermissionPage extends StatelessWidget {
}
}
/// 그룹 권한 기능이 활성화된 경우 사용하는 실제 화면 위젯.
class _GroupPermissionEnabledPage extends StatefulWidget {
const _GroupPermissionEnabledPage();
@@ -107,6 +109,7 @@ class _GroupPermissionEnabledPage extends StatefulWidget {
_GroupPermissionEnabledPageState();
}
/// 그룹 권한 목록/필터/폼 상태를 관리하는 상태 클래스.
class _GroupPermissionEnabledPageState
extends State<_GroupPermissionEnabledPage> {
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';
/// 메뉴(Menu) API 응답을 표현하는 DTO.
class MenuDto {
MenuDto({
this.id,
@@ -30,6 +31,7 @@ class MenuDto {
final DateTime? createdAt;
final DateTime? updatedAt;
/// JSON에서 메뉴 정보를 파싱한다.
factory MenuDto.fromJson(Map<String, dynamic> json) {
return MenuDto(
id: json['id'] as int?,
@@ -50,6 +52,7 @@ class MenuDto {
);
}
/// DTO를 도메인 [MenuItem]으로 변환한다.
MenuItem toEntity() => MenuItem(
id: id,
menuCode: menuCode,
@@ -64,6 +67,7 @@ class MenuDto {
updatedAt: updatedAt,
);
/// 페이징 응답을 [PaginatedResult]로 변환한다.
static PaginatedResult<MenuItem> parsePaginated(Map<String, dynamic>? json) {
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
final items = rawItems
@@ -79,12 +83,14 @@ class MenuDto {
}
}
/// 하위 메뉴 요약 정보를 담는 DTO.
class MenuSummaryDto {
MenuSummaryDto({required this.id, required this.menuName});
final int id;
final String menuName;
/// JSON에서 요약 정보를 파싱한다.
factory MenuSummaryDto.fromJson(Map<String, dynamic> json) {
return MenuSummaryDto(
id: json['id'] as int,
@@ -92,9 +98,11 @@ class MenuSummaryDto {
);
}
/// DTO를 [MenuSummary] 엔티티로 변환한다.
MenuSummary toEntity() => MenuSummary(id: id, menuName: menuName);
}
/// 문자열/DateTime을 파싱해 [DateTime]으로 변환한다.
DateTime? _parseDate(Object? value) {
if (value == null) return null;
if (value is DateTime) return value;

View File

@@ -6,6 +6,7 @@ import '../../domain/entities/menu.dart';
import '../../domain/repositories/menu_repository.dart';
import '../dtos/menu_dto.dart';
/// 메뉴 마스터 API를 호출하는 원격 저장소.
class MenuRepositoryRemote implements MenuRepository {
MenuRepositoryRemote({required ApiClient apiClient}) : _api = apiClient;
@@ -13,6 +14,7 @@ class MenuRepositoryRemote implements MenuRepository {
static const _basePath = '/menus';
/// 메뉴 목록을 조회한다.
@override
Future<PaginatedResult<MenuItem>> list({
int page = 1,
@@ -38,6 +40,7 @@ class MenuRepositoryRemote implements MenuRepository {
return MenuDto.parsePaginated(response.data ?? const {});
}
/// 새 메뉴를 생성한다.
@override
Future<MenuItem> create(MenuInput input) async {
final response = await _api.post<Map<String, dynamic>>(
@@ -49,6 +52,7 @@ class MenuRepositoryRemote implements MenuRepository {
return MenuDto.fromJson(data).toEntity();
}
/// 메뉴 정보를 수정한다.
@override
Future<MenuItem> update(int id, MenuInput input) async {
final response = await _api.patch<Map<String, dynamic>>(
@@ -60,11 +64,13 @@ class MenuRepositoryRemote implements MenuRepository {
return MenuDto.fromJson(data).toEntity();
}
/// 메뉴를 삭제한다.
@override
Future<void> delete(int id) async {
await _api.delete<void>('$_basePath/$id');
}
/// 삭제된 메뉴를 복구한다.
@override
Future<MenuItem> restore(int id) async {
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/repositories/menu_repository.dart';
/// 메뉴 사용 여부 필터.
enum MenuStatusFilter { all, activeOnly, inactiveOnly }
/// 메뉴 마스터 상태 컨트롤러
@@ -38,6 +39,7 @@ class MenuController extends ChangeNotifier {
String? get errorMessage => _errorMessage;
List<MenuItem> get parents => _parents;
/// 상위 메뉴 목록을 로드해 드롭다운에 표시한다.
Future<void> loadParents() async {
_isLoadingParents = true;
notifyListeners();
@@ -56,6 +58,7 @@ class MenuController extends ChangeNotifier {
}
}
/// 메뉴 목록을 조회한다.
Future<void> fetch({int page = 1}) async {
_isLoading = true;
_errorMessage = null;
@@ -83,26 +86,31 @@ class MenuController extends ChangeNotifier {
}
}
/// 검색어를 변경한다.
void updateQuery(String value) {
_query = value;
notifyListeners();
}
/// 상위 메뉴 필터를 변경한다.
void updateParentFilter(int? parentId) {
_parentFilter = parentId;
notifyListeners();
}
/// 메뉴 사용 여부 필터를 변경한다.
void updateStatusFilter(MenuStatusFilter filter) {
_statusFilter = filter;
notifyListeners();
}
/// 삭제 포함 여부를 변경한다.
void updateIncludeDeleted(bool value) {
_includeDeleted = value;
notifyListeners();
}
/// 메뉴를 생성한다.
Future<MenuItem?> create(MenuInput input) async {
_setSubmitting(true);
try {
@@ -119,6 +127,7 @@ class MenuController extends ChangeNotifier {
}
}
/// 메뉴 정보를 수정한다.
Future<MenuItem?> update(int id, MenuInput input) async {
_setSubmitting(true);
try {
@@ -135,6 +144,7 @@ class MenuController extends ChangeNotifier {
}
}
/// 메뉴를 삭제한다.
Future<bool> delete(int id) async {
_setSubmitting(true);
try {
@@ -151,6 +161,7 @@ class MenuController extends ChangeNotifier {
}
}
/// 삭제된 메뉴를 복구한다.
Future<MenuItem?> restore(int id) async {
_setSubmitting(true);
try {
@@ -167,11 +178,13 @@ class MenuController extends ChangeNotifier {
}
}
/// 에러 메시지를 초기화한다.
void clearError() {
_errorMessage = null;
notifyListeners();
}
/// 제출 상태 플래그를 갱신하고 리스너에 알린다.
void _setSubmitting(bool value) {
_isSubmitting = value;
notifyListeners();

View File

@@ -13,6 +13,7 @@ import '../../domain/entities/menu.dart';
import '../../domain/repositories/menu_repository.dart';
import '../controllers/menu_controller.dart' as menu;
/// 메뉴 관리 페이지. 기능 플래그에 따라 사양/실제 화면을 전환한다.
class MenuPage extends StatelessWidget {
const MenuPage({super.key});
@@ -89,6 +90,7 @@ class MenuPage extends StatelessWidget {
}
}
/// 메뉴 기능이 활성화된 경우 사용하는 실제 화면 위젯.
class _MenuEnabledPage extends StatefulWidget {
const _MenuEnabledPage();
@@ -96,6 +98,7 @@ class _MenuEnabledPage extends StatefulWidget {
State<_MenuEnabledPage> createState() => _MenuEnabledPageState();
}
/// 메뉴 목록과 필터를 관리하는 상태 클래스.
class _MenuEnabledPageState extends State<_MenuEnabledPage> {
late final menu.MenuController _controller;
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';
/// 제품(Product) API 응답을 표현하는 DTO.
class ProductDto {
ProductDto({
this.id,
@@ -28,6 +29,7 @@ class ProductDto {
final DateTime? createdAt;
final DateTime? updatedAt;
/// JSON에서 제품 정보를 파싱한다.
factory ProductDto.fromJson(Map<String, dynamic> json) {
return ProductDto(
id: json['id'] as int?,
@@ -47,6 +49,7 @@ class ProductDto {
);
}
/// DTO를 JSON 맵으로 직렬화한다.
Map<String, dynamic> toJson() {
return {
if (id != null) 'id': id,
@@ -62,6 +65,7 @@ class ProductDto {
};
}
/// DTO를 도메인 [Product] 엔티티로 변환한다.
Product toEntity() => Product(
id: id,
productCode: productCode,
@@ -75,6 +79,7 @@ class ProductDto {
updatedAt: updatedAt,
);
/// 엔티티 값을 DTO로 역변환한다.
static ProductDto fromEntity(Product entity) => ProductDto(
id: entity.id,
productCode: entity.productCode,
@@ -96,6 +101,7 @@ class ProductDto {
updatedAt: entity.updatedAt,
);
/// 페이징 응답을 [PaginatedResult]로 변환한다.
static PaginatedResult<Product> parsePaginated(Map<String, dynamic>? json) {
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
final items = rawItems
@@ -111,6 +117,7 @@ class ProductDto {
}
}
/// 제품에 연결된 공급업체 정보를 담는 DTO.
class ProductVendorDto {
ProductVendorDto({
required this.id,
@@ -122,6 +129,7 @@ class ProductVendorDto {
final String vendorCode;
final String vendorName;
/// JSON에서 공급업체 정보를 파싱한다.
factory ProductVendorDto.fromJson(Map<String, dynamic> json) {
return ProductVendorDto(
id: json['id'] as int,
@@ -130,20 +138,24 @@ class ProductVendorDto {
);
}
/// DTO를 JSON 맵으로 직렬화한다.
Map<String, dynamic> toJson() {
return {'id': id, 'vendor_code': vendorCode, 'vendor_name': vendorName};
}
/// DTO를 [ProductVendor] 엔티티로 변환한다.
ProductVendor toEntity() =>
ProductVendor(id: id, vendorCode: vendorCode, vendorName: vendorName);
}
/// 제품의 단위(UOM) 정보를 담는 DTO.
class ProductUomDto {
ProductUomDto({required this.id, required this.uomName});
final int id;
final String uomName;
/// JSON에서 단위 정보를 파싱한다.
factory ProductUomDto.fromJson(Map<String, dynamic> json) {
return ProductUomDto(
id: json['id'] as int,
@@ -151,13 +163,16 @@ class ProductUomDto {
);
}
/// DTO를 JSON 맵으로 직렬화한다.
Map<String, dynamic> toJson() {
return {'id': id, 'uom_name': uomName};
}
/// DTO를 [ProductUom] 엔티티로 변환한다.
ProductUom toEntity() => ProductUom(id: id, uomName: uomName);
}
/// 문자열/DateTime을 파싱해 [DateTime]으로 변환한다.
DateTime? _parseDate(Object? value) {
if (value == null) return null;
if (value is DateTime) return value;
@@ -165,6 +180,7 @@ DateTime? _parseDate(Object? value) {
return null;
}
/// 제품 입력 모델을 API 요청 바디로 변환한다.
Map<String, dynamic> productInputToJson(ProductInput input) {
final map = input.toPayload();
map.removeWhere((key, value) => value == null);

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ import '../../domain/entities/product.dart';
import '../../domain/repositories/product_repository.dart';
import '../controllers/product_controller.dart';
/// 제품 관리 페이지. 기능 플래그에 따라 사양/실제 화면을 전환한다.
class ProductPage extends StatelessWidget {
const ProductPage({super.key, required this.routeUri});
@@ -74,6 +75,7 @@ class ProductPage extends StatelessWidget {
}
}
/// 제품 기능이 활성화된 경우 사용하는 실제 화면 위젯.
class _ProductEnabledPage extends StatefulWidget {
const _ProductEnabledPage({required this.routeUri});
@@ -83,6 +85,7 @@ class _ProductEnabledPage extends StatefulWidget {
State<_ProductEnabledPage> createState() => _ProductEnabledPageState();
}
/// 제품 목록과 필터/폼 상태를 관리하는 상태 클래스.
class _ProductEnabledPageState extends State<_ProductEnabledPage> {
late final ProductController _controller;
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';
/// 단위(UOM) API 응답을 표현하는 DTO.
class UomDto {
UomDto({
this.id,
@@ -24,6 +25,7 @@ class UomDto {
final DateTime? createdAt;
final DateTime? updatedAt;
/// JSON에서 단위 정보를 파싱한다.
factory UomDto.fromJson(Map<String, dynamic> json) {
return UomDto(
id: json['id'] as int?,
@@ -37,6 +39,7 @@ class UomDto {
);
}
/// DTO를 도메인 [Uom] 엔티티로 변환한다.
Uom toEntity() => Uom(
id: id,
uomName: uomName,
@@ -48,6 +51,7 @@ class UomDto {
updatedAt: updatedAt,
);
/// 페이징 응답을 [PaginatedResult]로 변환한다.
static PaginatedResult<Uom> parsePaginated(Map<String, dynamic>? json) {
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
final items = rawItems
@@ -63,6 +67,7 @@ class UomDto {
}
}
/// 문자열/DateTime을 파싱해 [DateTime]으로 변환한다.
DateTime? _parseDate(Object? value) {
if (value == null) return null;
if (value is DateTime) return value;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ import '../../../vendor/domain/entities/vendor.dart';
import '../../../vendor/domain/repositories/vendor_repository.dart';
import '../controllers/vendor_controller.dart';
/// 벤더 관리 페이지. 기능 플래그에 따라 사양/실제 화면을 전환한다.
class VendorPage extends StatelessWidget {
const VendorPage({super.key, required this.routeUri});
@@ -67,6 +68,7 @@ class VendorPage extends StatelessWidget {
}
}
/// 벤더 기능이 활성화된 경우 사용하는 실제 화면 위젯.
class _VendorEnabledPage extends StatefulWidget {
const _VendorEnabledPage({required this.routeUri});
@@ -76,6 +78,7 @@ class _VendorEnabledPage extends StatefulWidget {
State<_VendorEnabledPage> createState() => _VendorEnabledPageState();
}
/// 벤더 목록과 필터/폼 상태를 관리하는 상태 클래스.
class _VendorEnabledPageState extends State<_VendorEnabledPage> {
late final VendorController _controller;
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';
/// 창고(Warehouse) API 응답을 표현하는 DTO.
class WarehouseDto {
WarehouseDto({
this.id,
@@ -28,6 +29,7 @@ class WarehouseDto {
final DateTime? createdAt;
final DateTime? updatedAt;
/// JSON에서 창고 정보를 파싱한다.
factory WarehouseDto.fromJson(Map<String, dynamic> json) {
return WarehouseDto(
id: json['id'] as int?,
@@ -47,6 +49,7 @@ class WarehouseDto {
);
}
/// DTO를 JSON 맵으로 직렬화한다.
Map<String, dynamic> toJson() {
return {
if (id != null) 'id': id,
@@ -62,6 +65,7 @@ class WarehouseDto {
};
}
/// DTO를 도메인 [Warehouse] 엔티티로 변환한다.
Warehouse toEntity() => Warehouse(
id: id,
warehouseCode: warehouseCode,
@@ -75,6 +79,7 @@ class WarehouseDto {
updatedAt: updatedAt,
);
/// 페이징 응답을 [PaginatedResult]로 변환한다.
static PaginatedResult<Warehouse> parsePaginated(Map<String, dynamic>? json) {
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
final items = rawItems
@@ -90,6 +95,7 @@ class WarehouseDto {
}
}
/// 창고 주소에 대한 우편번호 정보를 담는 DTO.
class WarehouseZipcodeDto {
WarehouseZipcodeDto({
required this.zipcode,
@@ -103,6 +109,7 @@ class WarehouseZipcodeDto {
final String? sigungu;
final String? roadName;
/// JSON에서 우편번호 정보를 파싱한다.
factory WarehouseZipcodeDto.fromJson(Map<String, dynamic> json) {
return WarehouseZipcodeDto(
zipcode: json['zipcode'] as String,
@@ -112,6 +119,7 @@ class WarehouseZipcodeDto {
);
}
/// DTO를 JSON 맵으로 직렬화한다.
Map<String, dynamic> toJson() {
return {
'zipcode': zipcode,
@@ -121,6 +129,7 @@ class WarehouseZipcodeDto {
};
}
/// DTO를 [WarehouseZipcode] 엔티티로 변환한다.
WarehouseZipcode toEntity() => WarehouseZipcode(
zipcode: zipcode,
sido: sido,
@@ -129,6 +138,7 @@ class WarehouseZipcodeDto {
);
}
/// 문자열/DateTime 값을 파싱해 [DateTime]으로 변환한다.
DateTime? _parseDate(Object? value) {
if (value == null) return null;
if (value is DateTime) return value;
@@ -136,6 +146,7 @@ DateTime? _parseDate(Object? value) {
return null;
}
/// 창고 입력 모델을 API 요청 바디로 변환한다.
Map<String, dynamic> warehouseInputToJson(WarehouseInput input) {
final map = input.toPayload();
map.removeWhere((key, value) => value == null);

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ import '../../domain/entities/warehouse.dart';
import '../../domain/repositories/warehouse_repository.dart';
import '../controllers/warehouse_controller.dart';
/// 창고 관리 페이지. 기능 플래그에 따라 사양/실제 화면을 전환한다.
class WarehousePage extends StatelessWidget {
const WarehousePage({super.key, required this.routeUri});
@@ -81,6 +82,7 @@ class WarehousePage extends StatelessWidget {
}
}
/// 창고 기능이 활성화된 경우 사용하는 실제 화면 위젯.
class _WarehouseEnabledPage extends StatefulWidget {
const _WarehouseEnabledPage({required this.routeUri});
@@ -90,6 +92,7 @@ class _WarehouseEnabledPage extends StatefulWidget {
State<_WarehouseEnabledPage> createState() => _WarehouseEnabledPageState();
}
/// 창고 목록과 필터 상태를 관리하는 상태 클래스.
class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
late final WarehouseController _controller;
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/superport_date_picker.dart';
/// 보고서 다운로드 화면 루트 위젯.
class ReportingPage extends StatefulWidget {
const ReportingPage({super.key});
@@ -20,6 +21,7 @@ class ReportingPage extends StatefulWidget {
State<ReportingPage> createState() => _ReportingPageState();
}
/// 보고서 페이지 UI 상태와 필터 조건을 관리하는 상태 클래스.
class _ReportingPageState extends State<ReportingPage> {
late final WarehouseRepository _warehouseRepository;
final intl.DateFormat _dateFormat = intl.DateFormat('yyyy.MM.dd');
@@ -99,6 +101,7 @@ class _ReportingPageState extends State<ReportingPage> {
}
}
/// 적용된 필터를 초기 상태로 되돌린다.
void _resetFilters() {
setState(() {
_appliedDateRange = null;
@@ -112,6 +115,7 @@ class _ReportingPageState extends State<ReportingPage> {
});
}
/// 대기 중인 필터 값을 실제 적용 상태로 확정한다.
void _applyFilters() {
setState(() {
_appliedDateRange = _pendingDateRange;

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import '../core/constants/app_sections.dart';
import '../core/theme/theme_controller.dart';
import '../core/permissions/permission_manager.dart';
/// 앱 기본 레이아웃을 제공하는 셸 위젯. 사이드 네비게이션과 AppBar를 구성한다.
class AppShell extends StatelessWidget {
const AppShell({
super.key,
@@ -248,10 +249,17 @@ class _ThemeMenuButton extends StatelessWidget {
(value) => PopupMenuItem<ThemeMode>(
value: value,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(_icon(value), size: 18),
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(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18),
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 {
SuperportToast._();
/// 성공 처리 완료를 사용자에게 안내한다.
static void success(BuildContext context, String message) {
_show(context, message, _ToastVariant.success);
}
/// 정보성 피드백을 노출한다.
static void info(BuildContext context, String message) {
_show(context, message, _ToastVariant.info);
}
/// 주의가 필요한 상황을 경고한다.
static void warning(BuildContext context, String message) {
_show(context, message, _ToastVariant.warning);
}
/// 오류 발생 시 스낵바를 표시한다.
static void error(BuildContext context, String message) {
_show(context, message, _ToastVariant.error);
}
/// 공통 스낵바 렌더링 로직.
static void _show(
BuildContext context,
String message,
@@ -106,9 +111,13 @@ class SuperportSkeletonList extends StatelessWidget {
this.padding = const EdgeInsets.all(16),
});
/// 렌더링할 스켈레톤 행 개수.
final int itemCount;
/// 각 항목 높이.
final double height;
/// 행 사이 간격.
final double gap;
/// 전체 패딩.
final EdgeInsetsGeometry padding;
@override

View File

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

View File

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

View File

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

View File

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

View File

@@ -62,6 +62,7 @@ class SuperportDialog extends StatelessWidget {
final FutureOr<void> Function()? onSubmit;
final bool enableFocusTrap;
/// 공통 다이얼로그를 노출하는 헬퍼. `showDialog`와 동일하게 동작한다.
static Future<T?> show<T>({
required BuildContext context,
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>({
required BuildContext context,
required String title,

View File

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

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
/// 기능 플래그 비활성화 시 사양 정보를 보여주기 위한 테이블 정의.
class SpecTable {
const SpecTable({
required this.columns,
@@ -13,6 +14,7 @@ class SpecTable {
final double? columnWidth;
}
/// 사양 페이지의 섹션 정보를 표현한다.
class SpecSection {
const SpecSection({
required this.title,
@@ -27,6 +29,7 @@ class SpecSection {
final SpecTable? table;
}
/// 기능 비활성 시 화면 대신 노출하는 사양 설명 페이지.
class SpecPage extends StatelessWidget {
const SpecPage({
super.key,
@@ -139,6 +142,7 @@ class SpecPage extends StatelessWidget {
}
}
/// 사양 테이블을 렌더링하는 내부 위젯.
class _SpecTableView extends StatelessWidget {
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/presentation/controllers/approval_controller.dart';
/// ApprovalRepository 모킹 클래스.
class _MockApprovalRepository extends Mock implements ApprovalRepository {}
/// Approval 생성 요청을 대체하기 위한 가짜 입력.
class _FakeApprovalInput extends Fake implements ApprovalInput {}
/// 단계 행위 요청을 대체하기 위한 가짜 입력.
class _FakeStepActionInput extends Fake implements ApprovalStepActionInput {}
/// ApprovalTemplateRepository 모킹 클래스.
class _MockApprovalTemplateRepository extends Mock
implements ApprovalTemplateRepository {}
/// 템플릿 단계 할당 요청을 대체하기 위한 가짜 입력.
class _FakeStepAssignmentInput extends Fake
implements ApprovalStepAssignmentInput {}
@@ -46,6 +51,7 @@ void main() {
histories: const [],
);
/// 테스트용 페이징 응답을 생성하는 헬퍼.
PaginatedResult<Approval> createResult(List<Approval> items) {
return PaginatedResult<Approval>(
items: items,
@@ -70,6 +76,7 @@ void main() {
);
});
// fetch 메서드 관련 시나리오
group('fetch', () {
setUp(() {
when(
@@ -86,6 +93,7 @@ void main() {
).thenAnswer((_) async => createResult([sampleApproval]));
});
// 정상적으로 결재 목록을 조회한다.
test('목록을 조회한다', () async {
await controller.fetch();
@@ -93,6 +101,7 @@ void main() {
expect(controller.errorMessage, isNull);
});
// 검색어/상태/기간 필터가 Repository 호출에 반영되는지 확인한다.
test('필터 전달을 검증한다', () async {
controller.updateQuery('TRX');
controller.updateStatusFilter(ApprovalStatusFilter.approved);
@@ -116,6 +125,7 @@ void main() {
).called(1);
});
// Repository 오류 발생 시 errorMessage가 설정된다.
test('에러 발생 시 errorMessage 설정', () async {
when(
() => repository.list(