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

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

View File

@@ -0,0 +1,66 @@
import 'package:flutter/widgets.dart';
import '../../../../../widgets/spec_page.dart';
class CustomerPage extends StatelessWidget {
const CustomerPage({super.key});
@override
Widget build(BuildContext context) {
return const SpecPage(
title: '회사(고객사) 관리',
summary: '고객사 기본 정보와 연락처, 주소를 관리합니다.',
sections: [
SpecSection(
title: '입력 폼',
items: [
'고객사코드 [Text]',
'고객사명 [Text]',
'유형 (파트너/일반) [Dropdown]',
'이메일 [Text]',
'연락처 [Text]',
'우편번호 [검색 연동], 상세주소 [Text]',
'사용여부 [Switch]',
'비고 [Text]',
],
),
SpecSection(
title: '수정 폼',
items: ['고객사코드 [ReadOnly]', '생성일시 [ReadOnly]'],
),
SpecSection(
title: '테이블 리스트',
description: '1행 예시',
table: SpecTable(
columns: [
'번호',
'고객사코드',
'고객사명',
'유형',
'이메일',
'연락처',
'우편번호',
'상세주소',
'사용여부',
'비고',
],
rows: [
[
'1',
'C-001',
'슈퍼포트 파트너',
'파트너',
'partner@superport.com',
'02-1234-5678',
'04532',
'서울시 중구 을지로 100',
'Y',
'-',
],
],
),
),
],
);
}
}

View File

@@ -0,0 +1,41 @@
import 'package:flutter/widgets.dart';
import '../../../../../widgets/spec_page.dart';
class GroupPage extends StatelessWidget {
const GroupPage({super.key});
@override
Widget build(BuildContext context) {
return const SpecPage(
title: '그룹 관리',
summary: '권한 그룹 정의와 기본여부 설정을 제공합니다.',
sections: [
SpecSection(
title: '입력 폼',
items: [
'그룹명 [Text]',
'그룹설명 [Text]',
'기본여부 [Switch]',
'사용여부 [Switch]',
'비고 [Text]',
],
),
SpecSection(
title: '수정 폼',
items: ['그룹명 [ReadOnly]', '생성일시 [ReadOnly]'],
),
SpecSection(
title: '테이블 리스트',
description: '1행 예시',
table: SpecTable(
columns: ['번호', '그룹명', '설명', '기본여부', '사용여부', '비고', '변경일시'],
rows: [
['1', '관리자', '시스템 전체 권한', 'Y', 'Y', '-', '2024-03-01 10:00'],
],
),
),
],
);
}
}

View File

@@ -0,0 +1,50 @@
import 'package:flutter/widgets.dart';
import '../../../../../widgets/spec_page.dart';
class GroupPermissionPage extends StatelessWidget {
const GroupPermissionPage({super.key});
@override
Widget build(BuildContext context) {
return const SpecPage(
title: '그룹 메뉴 권한 관리',
summary: '그룹별 메뉴 접근과 CRUD 권한을 설정합니다.',
sections: [
SpecSection(
title: '입력 폼',
items: [
'그룹 [Dropdown]',
'메뉴 [Dropdown]',
'생성권한 [Checkbox]',
'조회권한 [Checkbox]',
'수정권한 [Checkbox]',
'삭제권한 [Checkbox]',
'사용여부 [Switch]',
],
),
SpecSection(title: '수정 폼', items: ['그룹 [ReadOnly]', '메뉴 [ReadOnly]']),
SpecSection(
title: '테이블 리스트',
description: '1행 예시',
table: SpecTable(
columns: [
'번호',
'그룹명',
'메뉴명',
'생성',
'조회',
'수정',
'삭제',
'사용여부',
'변경일시',
],
rows: [
['1', '관리자', '대시보드', 'Y', 'Y', 'Y', 'Y', 'Y', '2024-03-01 10:00'],
],
),
),
],
);
}
}

View File

@@ -0,0 +1,52 @@
import 'package:flutter/widgets.dart';
import '../../../../../widgets/spec_page.dart';
class MenuPage extends StatelessWidget {
const MenuPage({super.key});
@override
Widget build(BuildContext context) {
return const SpecPage(
title: '메뉴 관리',
summary: '메뉴 계층, 경로, 노출 순서를 구성합니다.',
sections: [
SpecSection(
title: '입력 폼',
items: [
'메뉴코드 [Text]',
'메뉴명 [Text]',
'상위메뉴 [Dropdown]',
'경로 [Text]',
'표시순서 [Number]',
'사용여부 [Switch]',
'비고 [Text]',
],
),
SpecSection(
title: '수정 폼',
items: ['메뉴코드 [ReadOnly]', '생성일시 [ReadOnly]'],
),
SpecSection(
title: '테이블 리스트',
description: '1행 예시',
table: SpecTable(
columns: ['번호', '메뉴코드', '메뉴명', '상위메뉴', '경로', '사용여부', '비고', '변경일시'],
rows: [
[
'1',
'MN-001',
'대시보드',
'-',
'/dashboard',
'Y',
'-',
'2024-03-01 10:00',
],
],
),
),
],
);
}
}

View File

@@ -0,0 +1,51 @@
import 'package:flutter/widgets.dart';
import '../../../../../widgets/spec_page.dart';
class ProductPage extends StatelessWidget {
const ProductPage({super.key});
@override
Widget build(BuildContext context) {
return const SpecPage(
title: '장비 모델(제품) 관리',
summary: '제품 코드, 제조사, 단위 정보를 유지하여 재고 라인과 연계합니다.',
sections: [
SpecSection(
title: '입력 폼',
items: [
'제품코드 [Text]',
'제품명 [Text]',
'제조사 [Dropdown]',
'단위 [Dropdown]',
'사용여부 [Switch]',
'비고 [Text]',
],
),
SpecSection(
title: '수정 폼',
items: ['제품코드 [ReadOnly]', '생성일시 [ReadOnly]'],
),
SpecSection(
title: '테이블 리스트',
description: '1행 예시',
table: SpecTable(
columns: ['번호', '제품코드', '제품명', '제조사', '단위', '사용여부', '비고', '변경일시'],
rows: [
[
'1',
'P-100',
'XR-5000',
'슈퍼벤더',
'EA',
'Y',
'-',
'2024-03-01 10:00',
],
],
),
),
],
);
}
}

View File

@@ -0,0 +1,60 @@
import 'package:flutter/widgets.dart';
import '../../../../../widgets/spec_page.dart';
class UserPage extends StatelessWidget {
const UserPage({super.key});
@override
Widget build(BuildContext context) {
return const SpecPage(
title: '사용자(사원) 관리',
summary: '사번 기반 계정과 그룹, 사용 상태를 관리합니다.',
sections: [
SpecSection(
title: '입력 폼',
items: [
'사번 [Text]',
'성명 [Text]',
'이메일 [Text]',
'연락처 [Text]',
'그룹 [Dropdown]',
'사용여부 [Switch]',
'비고 [Text]',
],
),
SpecSection(title: '수정 폼', items: ['사번 [ReadOnly]', '생성일시 [ReadOnly]']),
SpecSection(
title: '테이블 리스트',
description: '1행 예시',
table: SpecTable(
columns: [
'번호',
'사번',
'성명',
'이메일',
'연락처',
'그룹',
'사용여부',
'비고',
'변경일시',
],
rows: [
[
'1',
'A0001',
'김철수',
'kim@superport.com',
'010-1111-2222',
'관리자',
'Y',
'-',
'2024-03-01 10:00',
],
],
),
),
],
);
}
}

View File

@@ -0,0 +1,80 @@
import '../../domain/entities/vendor.dart';
/// 벤더 DTO (JSON 직렬화/역직렬화)
class VendorDto {
VendorDto({
this.id,
required this.vendorCode,
required this.vendorName,
this.isActive = true,
this.isDeleted = false,
this.note,
this.createdAt,
this.updatedAt,
});
final int? id;
final String vendorCode;
final String vendorName;
final bool isActive;
final bool isDeleted;
final String? note;
final DateTime? createdAt;
final DateTime? updatedAt;
factory VendorDto.fromJson(Map<String, dynamic> json) {
return VendorDto(
id: json['id'] as int?,
vendorCode: json['vendor_code'] as String,
vendorName: json['vendor_name'] as String,
isActive: (json['is_active'] as bool?) ?? true,
isDeleted: (json['is_deleted'] as bool?) ?? false,
note: json['note'] as String?,
createdAt: _parseDate(json['created_at']),
updatedAt: _parseDate(json['updated_at']),
);
}
Map<String, dynamic> toJson() {
return {
if (id != null) 'id': id,
'vendor_code': vendorCode,
'vendor_name': vendorName,
'is_active': isActive,
'is_deleted': isDeleted,
'note': note,
'created_at': createdAt?.toIso8601String(),
'updated_at': updatedAt?.toIso8601String(),
};
}
Vendor toEntity() => Vendor(
id: id,
vendorCode: vendorCode,
vendorName: vendorName,
isActive: isActive,
isDeleted: isDeleted,
note: note,
createdAt: createdAt,
updatedAt: updatedAt,
);
static VendorDto fromEntity(Vendor entity) => VendorDto(
id: entity.id,
vendorCode: entity.vendorCode,
vendorName: entity.vendorName,
isActive: entity.isActive,
isDeleted: entity.isDeleted,
note: entity.note,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
);
}
DateTime? _parseDate(Object? value) {
if (value == null) return null;
if (value is DateTime) return value;
if (value is String) return DateTime.tryParse(value);
return null;
}

View File

@@ -0,0 +1,70 @@
import 'package:dio/dio.dart';
import '../../domain/entities/vendor.dart';
import '../../domain/repositories/vendor_repository.dart';
import '../dtos/vendor_dto.dart';
import '../../../../../core/network/api_client.dart';
/// 원격 구현체: 공통 ApiClient(Dio) 사용
class VendorRepositoryRemote implements VendorRepository {
VendorRepositoryRemote({required ApiClient apiClient}) : _api = apiClient;
final ApiClient _api;
static const _basePath = '/vendors'; // TODO: 백엔드 경로 확정 시 수정
@override
Future<List<Vendor>> list({
int page = 1,
int pageSize = 20,
String? query,
bool includeInactive = true,
}) async {
final response = await _api.get<List<dynamic>>(
_basePath,
query: {
'page': page,
'page_size': pageSize,
if (query != null && query.isNotEmpty) 'q': query,
if (includeInactive) 'include': 'inactive',
},
options: Options(responseType: ResponseType.json),
);
final data = response.data ?? [];
return data
.whereType<Map<String, dynamic>>()
.map((e) => VendorDto.fromJson(e).toEntity())
.toList();
}
@override
Future<Vendor> create(Vendor vendor) async {
final dto = VendorDto.fromEntity(vendor);
final response = await _api.post<Map<String, dynamic>>(
_basePath,
data: dto.toJson(),
options: Options(responseType: ResponseType.json),
);
return VendorDto.fromJson(response.data ?? {}).toEntity();
}
@override
Future<Vendor> update(Vendor vendor) async {
if (vendor.id == null) {
throw ArgumentError('id가 없는 엔티티는 수정할 수 없습니다.');
}
final dto = VendorDto.fromEntity(vendor);
final response = await _api.patch<Map<String, dynamic>>(
'$_basePath/${vendor.id}',
data: dto.toJson(),
options: Options(responseType: ResponseType.json),
);
return VendorDto.fromJson(response.data ?? {}).toEntity();
}
@override
Future<void> delete(int id) async {
await _api.delete<void>('$_basePath/$id');
}
}

View File

@@ -0,0 +1,61 @@
/// 벤더(제조사) 도메인 엔티티
///
/// - SRP: 벤더의 속성 표현만 담당
/// - data/presentation 레이어에 의존하지 않음
class Vendor {
Vendor({
this.id,
required this.vendorCode,
required this.vendorName,
this.isActive = true,
this.isDeleted = false,
this.note,
this.createdAt,
this.updatedAt,
});
/// PK (DB bigint), 신규 생성 시 null
final int? id;
/// 벤더코드 (부분유니크: is_deleted=false)
final String vendorCode;
/// 벤더명
final String vendorName;
/// 사용 여부
final bool isActive;
/// 소프트 삭제 여부
final bool isDeleted;
/// 비고
final String? note;
/// 생성/변경 일시 (선택)
final DateTime? createdAt;
final DateTime? updatedAt;
Vendor copyWith({
int? id,
String? vendorCode,
String? vendorName,
bool? isActive,
bool? isDeleted,
String? note,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return Vendor(
id: id ?? this.id,
vendorCode: vendorCode ?? this.vendorCode,
vendorName: vendorName ?? this.vendorName,
isActive: isActive ?? this.isActive,
isDeleted: isDeleted ?? this.isDeleted,
note: note ?? this.note,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}

View File

@@ -0,0 +1,27 @@
import '../entities/vendor.dart';
/// 벤더 리포지토리 인터페이스
///
/// - presentation → domain → data 방향을 보장하기 위해 domain에 위치
/// - 실제 구현은 data 레이어에서 제공한다.
abstract class VendorRepository {
/// 벤더 목록 조회
///
/// - 표준 쿼리 파라미터: page, page_size, q, include
Future<List<Vendor>> list({
int page = 1,
int pageSize = 20,
String? query,
bool includeInactive = true,
});
/// 벤더 생성
Future<Vendor> create(Vendor vendor);
/// 벤더 수정 (부분 업데이트 포함)
Future<Vendor> update(Vendor vendor);
/// 벤더 소프트 삭제
Future<void> delete(int id);
}

View File

@@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../../core/config/environment.dart';
import '../../../../../widgets/spec_page.dart';
import '../../../vendor/domain/entities/vendor.dart';
import '../../../vendor/domain/repositories/vendor_repository.dart';
class VendorPage extends StatelessWidget {
const VendorPage({super.key});
@override
Widget build(BuildContext context) {
final enabled = Environment.flag('FEATURE_VENDORS_ENABLED');
if (!enabled) {
return SpecPage(
title: '제조사(벤더) 관리',
summary: '벤더 기본 정보를 등록하고 사용여부를 제어합니다.',
trailing: ShadBadge(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(LucideIcons.info, size: 14),
const SizedBox(width: 6),
Text('비활성화 (백엔드 준비 중)'),
],
),
),
),
sections: const [
SpecSection(
title: '입력 폼',
items: ['벤더코드 [Text]', '벤더명 [Text]', '사용여부 [Switch]', '비고 [Text]'],
),
SpecSection(
title: '수정 폼',
items: ['벤더코드 [ReadOnly]', '생성일시 [ReadOnly]', '수정일시 [ReadOnly]'],
),
SpecSection(
title: '테이블 리스트',
description: '1행 예시',
table: SpecTable(
columns: ['번호', '벤더코드', '벤더명', '사용여부', '비고', '변경일시'],
rows: [
['1', 'V-001', '슈퍼벤더', 'Y', '-', '2024-03-01 10:00'],
],
),
),
],
);
}
return const _VendorEnabledPage();
}
}
class _VendorEnabledPage extends StatefulWidget {
const _VendorEnabledPage();
@override
State<_VendorEnabledPage> createState() => _VendorEnabledPageState();
}
class _VendorEnabledPageState extends State<_VendorEnabledPage> {
final _repo = GetIt.I<VendorRepository>();
final _loading = ValueNotifier(false);
final _vendors = ValueNotifier<List<Vendor>>([]);
@override
void dispose() {
_loading.dispose();
_vendors.dispose();
super.dispose();
}
Future<void> _load() async {
_loading.value = true;
try {
final list = await _repo.list(page: 1, pageSize: 50);
_vendors.value = list;
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('벤더 조회 실패: $e')),
);
}
} finally {
_loading.value = false;
}
}
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('제조사(벤더) 관리', style: theme.textTheme.h2),
const SizedBox(height: 6),
Text('벤더코드, 명칭, 사용여부 관리', style: theme.textTheme.muted),
],
),
Row(
children: [
ValueListenableBuilder<bool>(
valueListenable: _loading,
builder: (_, loading, __) {
return ShadButton(
onPressed: loading ? null : _load,
child: Text(loading ? '로딩 중...' : '데이터 조회'),
);
},
),
],
),
],
),
const SizedBox(height: 16),
ShadCard(
title: Text('벤더 목록', style: theme.textTheme.h3),
child: ValueListenableBuilder<List<Vendor>>(
valueListenable: _vendors,
builder: (_, vendors, __) {
if (vendors.isEmpty) {
return Padding(
padding: const EdgeInsets.all(24),
child: Text('데이터가 없습니다. 상단의 "데이터 조회"를 눌러주세요.',
style: theme.textTheme.muted),
);
}
return SizedBox(
height: 56.0 * (vendors.length + 1),
child: ShadTable.list(
header: const [
'ID',
'벤더코드',
'벤더명',
'사용',
'비고',
'변경일시',
].map((h) => ShadTableCell.header(child: Text(h))).toList(),
children: vendors
.map(
(v) => [
'${v.id ?? '-'}',
v.vendorCode,
v.vendorName,
v.isActive ? 'Y' : 'N',
v.note ?? '-',
v.updatedAt?.toIso8601String() ?? '-',
].map((c) => ShadTableCell(child: Text(c))).toList(),
)
.toList(),
columnSpanExtent: (index) => const FixedTableSpanExtent(160),
),
);
},
),
),
],
),
);
}
}

View File

@@ -0,0 +1,60 @@
import 'package:flutter/widgets.dart';
import '../../../../../widgets/spec_page.dart';
class WarehousePage extends StatelessWidget {
const WarehousePage({super.key});
@override
Widget build(BuildContext context) {
return const SpecPage(
title: '입고지(창고) 관리',
summary: '창고 주소와 사용 여부를 구성합니다.',
sections: [
SpecSection(
title: '입력 폼',
items: [
'창고코드 [Text]',
'창고명 [Text]',
'우편번호 [검색 연동]',
'상세주소 [Text]',
'사용여부 [Switch]',
'비고 [Text]',
],
),
SpecSection(
title: '수정 폼',
items: ['창고코드 [ReadOnly]', '생성일시 [ReadOnly]'],
),
SpecSection(
title: '테이블 리스트',
description: '1행 예시',
table: SpecTable(
columns: [
'번호',
'창고코드',
'창고명',
'우편번호',
'상세주소',
'사용여부',
'비고',
'변경일시',
],
rows: [
[
'1',
'WH-01',
'서울 1창고',
'04532',
'서울시 중구 을지로 100',
'Y',
'-',
'2024-03-01 10:00',
],
],
),
),
],
);
}
}