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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
// ignore_for_file: public_member_api_docs
import 'package:dio/dio.dart';
/// 인증 인터셉터(스켈레톤)
/// - 요청 전에 Authorization 헤더 주입
/// - 401 수신 시 토큰 갱신 및 원요청 1회 재시도 (구현 예정)
class AuthInterceptor extends Interceptor {
/// TODO: 토큰 저장/조회 서비스 주입 (예: AuthRepository)
AuthInterceptor();
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
// TODO: 저장된 토큰을 읽어 Authorization 헤더에 주입한다.
// final token = await _authRepository.getToken();
// if (token != null && token.isNotEmpty) {
// options.headers['Authorization'] = 'Bearer $token';
// }
handler.next(options);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
// TODO: 401 처리 로직(토큰 갱신 → 원요청 재시도) 구현
// if (err.response?.statusCode == 401) { ... }
handler.next(err);
}
}

View File

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

View File

@@ -0,0 +1,47 @@
import 'package:flutter/widgets.dart';
import '../../../../../widgets/spec_page.dart';
class ApprovalHistoryPage extends StatelessWidget {
const ApprovalHistoryPage({super.key});
@override
Widget build(BuildContext context) {
return const SpecPage(
title: '결재 이력 조회',
summary: '결재 단계별 변경 이력을 조회합니다.',
sections: [
SpecSection(
title: '조회 테이블',
description: '수정 없이 이력 리스트만 제공.',
table: SpecTable(
columns: [
'번호',
'결재ID',
'단계ID',
'승인자',
'행위',
'변경전상태',
'변경후상태',
'작업일시',
'비고',
],
rows: [
[
'1',
'APP-20240301-001',
'STEP-1',
'최관리',
'승인',
'승인대기',
'승인완료',
'2024-03-01 10:30',
'-',
],
],
),
),
],
);
}
}

View File

@@ -0,0 +1,59 @@
import 'package:flutter/widgets.dart';
import '../../../../../widgets/spec_page.dart';
class ApprovalRequestPage extends StatelessWidget {
const ApprovalRequestPage({super.key});
@override
Widget build(BuildContext context) {
return const SpecPage(
title: '결재 관리',
summary: '결재 번호와 상태, 상신자를 확인하고 결재 플로우를 제어합니다.',
sections: [
SpecSection(
title: '입력 폼',
items: [
'트랜잭션번호 [Dropdown]',
'결재번호 [자동생성]',
'결재상태 [Dropdown]',
'상신자 [자동]',
'비고 [Text]',
],
),
SpecSection(
title: '수정 폼',
items: ['결재번호 [ReadOnly]', '상신자 [ReadOnly]', '요청일시 [ReadOnly]'],
),
SpecSection(
title: '테이블 리스트',
description: '1행 예시',
table: SpecTable(
columns: [
'번호',
'결재번호',
'트랜잭션번호',
'상태',
'상신자',
'요청일시',
'최종결정일시',
'비고',
],
rows: [
[
'1',
'APP-20240301-001',
'IN-20240301-001',
'승인대기',
'홍길동',
'2024-03-01 09:00',
'-',
'-',
],
],
),
),
],
);
}
}

View File

@@ -0,0 +1,50 @@
import 'package:flutter/widgets.dart';
import '../../../../../widgets/spec_page.dart';
class ApprovalStepPage extends StatelessWidget {
const ApprovalStepPage({super.key});
@override
Widget build(BuildContext context) {
return const SpecPage(
title: '결재 단계 관리',
summary: '결재 단계 순서와 승인자를 구성합니다.',
sections: [
SpecSection(
title: '입력 폼',
items: [
'결재ID [Dropdown]',
'단계순서 [Number]',
'승인자 [Dropdown]',
'단계상태 [Dropdown]',
'비고 [Text]',
],
),
SpecSection(
title: '수정 폼',
items: ['결재ID [ReadOnly]', '단계순서 [ReadOnly]'],
),
SpecSection(
title: '테이블 리스트',
description: '1행 예시',
table: SpecTable(
columns: ['번호', '결재ID', '단계순서', '승인자', '상태', '배정일시', '결정일시', '비고'],
rows: [
[
'1',
'APP-20240301-001',
'1',
'최관리',
'승인대기',
'2024-03-01 09:00',
'-',
'-',
],
],
),
),
],
);
}
}

View File

@@ -0,0 +1,51 @@
import 'package:flutter/widgets.dart';
import '../../../../../widgets/spec_page.dart';
class ApprovalTemplatePage extends StatelessWidget {
const ApprovalTemplatePage({super.key});
@override
Widget build(BuildContext context) {
return const SpecPage(
title: '결재 템플릿 관리',
summary: '반복적인 결재 흐름을 템플릿으로 정의합니다.',
sections: [
SpecSection(
title: '입력 폼',
items: [
'템플릿코드 [Text]',
'템플릿명 [Text]',
'설명 [Text]',
'작성자 [ReadOnly]',
'사용여부 [Switch]',
'비고 [Text]',
'단계 추가: 순서 [Number], 승인자 [Dropdown]',
],
),
SpecSection(
title: '수정 폼',
items: ['템플릿코드 [ReadOnly]', '작성자 [ReadOnly]'],
),
SpecSection(
title: '테이블 리스트',
description: '1행 예시',
table: SpecTable(
columns: ['번호', '템플릿코드', '템플릿명', '설명', '작성자', '사용여부', '변경일시'],
rows: [
[
'1',
'TEMP-001',
'입고 기본 결재',
'입고 처리 2단계 결재',
'홍길동',
'Y',
'2024-03-01 10:00',
],
],
),
),
],
);
}
}

View File

@@ -0,0 +1,25 @@
import 'package:flutter/widgets.dart';
import '../../../../widgets/spec_page.dart';
class DashboardPage extends StatelessWidget {
const DashboardPage({super.key});
@override
Widget build(BuildContext context) {
return const SpecPage(
title: '대시보드',
summary: '오늘 입고/출고, 결재 대기, 최근 트랜잭션을 한 눈에 볼 수 있는 메인 화면 구성.',
sections: [
SpecSection(
title: '주요 위젯',
items: [
'오늘 입고/출고 건수, 대기 결재 수 KPI 카드',
'최근 트랜잭션 리스트: 번호 · 일자 · 유형 · 상태 · 작성자',
'내 결재 요청/대기 건 알림 패널',
],
),
],
);
}
}

View File

@@ -0,0 +1,960 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
class InboundPage extends StatefulWidget {
const InboundPage({super.key});
@override
State<InboundPage> createState() => _InboundPageState();
}
class _InboundPageState extends State<InboundPage> {
final TextEditingController _searchController = TextEditingController();
final DateFormat _dateFormatter = DateFormat('yyyy-MM-dd');
final NumberFormat _currencyFormatter = NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
);
DateTimeRange? _dateRange;
final List<InboundRecord> _records = _mockRecords;
InboundRecord? _selectedRecord;
static const _statusOptions = ['작성중', '승인대기', '승인완료'];
static const _warehouseOptions = ['서울 1창고', '부산 센터', '대전 물류'];
@override
void initState() {
super.initState();
if (_records.isNotEmpty) {
_selectedRecord = _records.first;
}
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final filtered = _filteredRecords;
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('입고 관리', style: theme.textTheme.h2),
const SizedBox(height: 6),
Text(
'입고 처리, 라인 품목, 상태를 한 화면에서 확인하고 관리합니다.',
style: theme.textTheme.muted,
),
],
),
),
Row(
children: [
ShadButton(
leading: const Icon(LucideIcons.plus, size: 16),
onPressed: _handleCreate,
child: const Text('입고 등록'),
),
const SizedBox(width: 12),
ShadButton.outline(
leading: const Icon(LucideIcons.pencil, size: 16),
onPressed: _selectedRecord == null
? null
: () => _handleEdit(_selectedRecord!),
child: const Text('선택 항목 수정'),
),
],
),
],
),
const SizedBox(height: 24),
ShadCard(
title: Text('검색 필터', style: theme.textTheme.h3),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 16,
runSpacing: 16,
children: [
SizedBox(
width: 260,
child: ShadInput(
controller: _searchController,
placeholder: const Text('트랜잭션번호, 작성자, 제품 검색'),
leading: const Icon(LucideIcons.search, size: 16),
onChanged: (_) => setState(() {}),
),
),
SizedBox(
width: 220,
child: ShadButton.outline(
onPressed: _pickDateRange,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
const Icon(LucideIcons.calendar, size: 16),
const SizedBox(width: 8),
Text(
_dateRange == null
? '기간 선택'
: '${_dateFormatter.format(_dateRange!.start)} ~ ${_dateFormatter.format(_dateRange!.end)}',
),
],
),
),
),
if (_dateRange != null)
ShadButton.ghost(
onPressed: () => setState(() => _dateRange = null),
child: const Text('기간 초기화'),
),
],
),
],
),
),
const SizedBox(height: 24),
ShadCard(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('입고 내역', style: theme.textTheme.h3),
Text('${filtered.length}', style: theme.textTheme.muted),
],
),
child: SizedBox(
height: 420,
child: filtered.isEmpty
? Center(
child: Text(
'조건에 맞는 입고 내역이 없습니다.',
style: theme.textTheme.muted,
),
)
: ShadTable.list(
header: _tableHeaders
.map(
(header) =>
ShadTableCell.header(child: Text(header)),
)
.toList(),
children: [
for (final record in filtered)
_buildRecordRow(record).map(
(value) => ShadTableCell(
child: Text(
value,
overflow: TextOverflow.ellipsis,
),
),
),
],
columnSpanExtent: (index) =>
const FixedTableSpanExtent(140),
rowSpanExtent: (index) => const FixedTableSpanExtent(56),
onRowTap: (rowIndex) {
setState(() {
_selectedRecord = filtered[rowIndex];
});
},
),
),
),
if (_selectedRecord != null) ...[
const SizedBox(height: 24),
_DetailCard(
record: _selectedRecord!,
dateFormatter: _dateFormatter,
currencyFormatter: _currencyFormatter,
onEdit: () => _handleEdit(_selectedRecord!),
),
],
],
),
);
}
List<InboundRecord> get _filteredRecords {
final query = _searchController.text.trim().toLowerCase();
return _records.where((record) {
final matchesQuery =
query.isEmpty ||
record.number.toLowerCase().contains(query) ||
record.transactionNumber.toLowerCase().contains(query) ||
record.writer.toLowerCase().contains(query) ||
record.items.any(
(item) => item.product.toLowerCase().contains(query),
);
final matchesRange =
_dateRange == null ||
(!record.processedAt.isBefore(_dateRange!.start) &&
!record.processedAt.isAfter(_dateRange!.end));
return matchesQuery && matchesRange;
}).toList()..sort((a, b) => b.processedAt.compareTo(a.processedAt));
}
List<String> _buildRecordRow(InboundRecord record) {
final primaryItem = record.items.first;
return [
record.number.split('-').last,
_dateFormatter.format(record.processedAt),
record.warehouse,
record.transactionNumber,
primaryItem.product,
primaryItem.manufacturer,
primaryItem.unit,
record.totalQuantity.toString(),
_currencyFormatter.format(primaryItem.price),
record.status,
record.writer,
record.itemCount.toString(),
record.totalQuantity.toString(),
record.remark.isEmpty ? '-' : record.remark,
];
}
Future<void> _pickDateRange() async {
final now = DateTime.now();
final range = await showDateRangePicker(
context: context,
firstDate: DateTime(now.year - 5),
lastDate: DateTime(now.year + 5),
initialDateRange: _dateRange,
);
if (range != null) {
setState(() => _dateRange = range);
}
}
Future<void> _handleCreate() async {
final record = await _showInboundFormDialog();
if (record != null) {
setState(() {
_records.insert(0, record);
_selectedRecord = record;
});
}
}
Future<void> _handleEdit(InboundRecord record) async {
final updated = await _showInboundFormDialog(initial: record);
if (updated != null) {
setState(() {
final index = _records.indexWhere(
(element) => element.number == record.number,
);
if (index != -1) {
_records[index] = updated;
_selectedRecord = updated;
}
});
}
}
Future<InboundRecord?> _showInboundFormDialog({
InboundRecord? initial,
}) async {
final processedAt = ValueNotifier<DateTime>(
initial?.processedAt ?? DateTime.now(),
);
final warehouseController = TextEditingController(
text: initial?.warehouse ?? _warehouseOptions.first,
);
final statusValue = ValueNotifier<String>(
initial?.status ?? _statusOptions.first,
);
final writerController = TextEditingController(
text: initial?.writer ?? '홍길동',
);
final remarkController = TextEditingController(text: initial?.remark ?? '');
final drafts =
initial?.items
.map((item) => _LineItemDraft.fromItem(item))
.toList()
.cast<_LineItemDraft>() ??
[_LineItemDraft.empty()];
InboundRecord? result;
await showDialog<void>(
context: context,
builder: (dialogContext) {
final theme = ShadTheme.of(dialogContext);
return StatefulBuilder(
builder: (context, setState) {
return Dialog(
insetPadding: const EdgeInsets.all(24),
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 860,
maxHeight: 720,
),
child: ShadCard(
title: Text(
initial == null ? '입고 등록' : '입고 수정',
style: theme.textTheme.h3,
),
description: Text(
'입고 기본정보와 품목 라인을 입력하세요.',
style: theme.textTheme.muted,
),
footer: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ShadButton.ghost(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('취소'),
),
const SizedBox(width: 12),
ShadButton(
onPressed: () {
if (drafts.any(
(draft) => draft.product.text.isEmpty,
)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('품목 정보를 입력하세요.')),
);
return;
}
final items = drafts
.map(
(draft) => InboundLineItem(
product: draft.product.text,
manufacturer: draft.manufacturer.text,
unit: draft.unit.text,
quantity:
int.tryParse(draft.quantity.text) ?? 0,
price:
double.tryParse(
draft.price.text.replaceAll(',', ''),
) ??
0,
remark: draft.remark.text,
),
)
.toList();
final record = InboundRecord(
number:
initial?.number ??
_generateInboundNumber(processedAt.value),
transactionNumber:
initial?.transactionNumber ??
_generateTransactionNumber(processedAt.value),
processedAt: processedAt.value,
warehouse: warehouseController.text,
status: statusValue.value,
writer: writerController.text,
remark: remarkController.text,
items: items,
);
result = record;
Navigator.of(dialogContext).pop();
},
child: const Text('저장'),
),
],
),
child: SingleChildScrollView(
padding: const EdgeInsets.only(right: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 16,
runSpacing: 16,
children: [
SizedBox(
width: 240,
child: _FormFieldLabel(
label: '처리일자',
child: ShadButton.outline(
onPressed: () async {
final picked = await showDatePicker(
context: context,
initialDate: processedAt.value,
firstDate: DateTime(2020),
lastDate: DateTime(2030),
);
if (picked != null) {
processedAt.value = picked;
setState(() {});
}
},
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
_dateFormatter.format(
processedAt.value,
),
),
const Icon(
LucideIcons.calendar,
size: 16,
),
],
),
),
),
),
SizedBox(
width: 240,
child: _FormFieldLabel(
label: '창고',
child: ShadSelect<String>(
initialValue: warehouseController.text,
selectedOptionBuilder: (context, value) =>
Text(value),
onChanged: (value) {
if (value != null) {
warehouseController.text = value;
setState(() {});
}
},
options: _warehouseOptions
.map(
(option) => ShadOption(
value: option,
child: Text(option),
),
)
.toList(),
),
),
),
SizedBox(
width: 240,
child: _FormFieldLabel(
label: '상태',
child: ShadSelect<String>(
initialValue: statusValue.value,
selectedOptionBuilder: (context, value) =>
Text(value),
onChanged: (value) {
if (value != null) {
statusValue.value = value;
setState(() {});
}
},
options: _statusOptions
.map(
(status) => ShadOption(
value: status,
child: Text(status),
),
)
.toList(),
),
),
),
SizedBox(
width: 240,
child: _FormFieldLabel(
label: '작성자',
child: ShadInput(controller: writerController),
),
),
SizedBox(
width: 500,
child: _FormFieldLabel(
label: '비고',
child: ShadInput(
controller: remarkController,
maxLines: 2,
),
),
),
],
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('라인 품목', style: theme.textTheme.h4),
const SizedBox(height: 4),
Text(
'제품, 제조사, 단위, 수량, 단가 정보를 입력하세요.',
style: theme.textTheme.muted,
),
],
),
ShadButton.outline(
leading: const Icon(LucideIcons.plus, size: 16),
onPressed: () => setState(() {
drafts.add(_LineItemDraft.empty());
}),
child: const Text('품목 추가'),
),
],
),
const SizedBox(height: 16),
Column(
children: [
for (final draft in drafts)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _LineItemRow(
draft: draft,
onRemove: drafts.length == 1
? null
: () => setState(() {
draft.dispose();
drafts.remove(draft);
}),
),
),
],
),
],
),
),
),
),
);
},
);
},
);
for (final draft in drafts) {
draft.dispose();
}
warehouseController.dispose();
statusValue.dispose();
writerController.dispose();
remarkController.dispose();
processedAt.dispose();
return result;
}
static String _generateInboundNumber(DateTime date) {
final stamp = DateFormat('yyyyMMdd-HHmmss').format(date);
return 'IN-$stamp';
}
static String _generateTransactionNumber(DateTime date) {
final stamp = DateFormat('yyyyMMdd-HHmmss').format(date);
return 'TX-$stamp';
}
static const _tableHeaders = [
'번호',
'처리일자',
'창고',
'트랜잭션번호',
'제품',
'제조사',
'단위',
'수량',
'단가',
'상태',
'작성자',
'품목수',
'총수량',
'비고',
];
}
class _DetailCard extends StatelessWidget {
const _DetailCard({
required this.record,
required this.dateFormatter,
required this.currencyFormatter,
required this.onEdit,
});
final InboundRecord record;
final DateFormat dateFormatter;
final NumberFormat currencyFormatter;
final VoidCallback onEdit;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return ShadCard(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('선택된 입고 상세', style: theme.textTheme.h3),
ShadButton.outline(
leading: const Icon(LucideIcons.pencil, size: 16),
onPressed: onEdit,
child: const Text('수정'),
),
],
),
description: Text(
'트랜잭션번호 ${record.transactionNumber}',
style: theme.textTheme.muted,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 12,
runSpacing: 12,
children: [
_DetailChip(
label: '처리일자',
value: dateFormatter.format(record.processedAt),
),
_DetailChip(label: '창고', value: record.warehouse),
_DetailChip(label: '상태', value: record.status),
_DetailChip(label: '작성자', value: record.writer),
_DetailChip(label: '품목 수', value: '${record.itemCount}'),
_DetailChip(label: '총 수량', value: '${record.totalQuantity}'),
_DetailChip(
label: '총 금액',
value: currencyFormatter.format(record.totalAmount),
),
],
),
const SizedBox(height: 24),
Text('라인 품목', style: theme.textTheme.h4),
const SizedBox(height: 8),
SizedBox(
height: (record.items.length * 52).clamp(160, 260).toDouble(),
child: ShadTable.list(
header: const [
ShadTableCell.header(child: Text('제품')),
ShadTableCell.header(child: Text('제조사')),
ShadTableCell.header(child: Text('단위')),
ShadTableCell.header(child: Text('수량')),
ShadTableCell.header(child: Text('단가')),
ShadTableCell.header(child: Text('비고')),
],
children: [
for (final item in record.items)
[
ShadTableCell(child: Text(item.product)),
ShadTableCell(child: Text(item.manufacturer)),
ShadTableCell(child: Text(item.unit)),
ShadTableCell(child: Text('${item.quantity}')),
ShadTableCell(
child: Text(currencyFormatter.format(item.price)),
),
ShadTableCell(
child: Text(item.remark.isEmpty ? '-' : item.remark),
),
],
],
columnSpanExtent: (index) => const FixedTableSpanExtent(136),
rowSpanExtent: (index) => const FixedTableSpanExtent(52),
),
),
],
),
);
}
}
class _DetailChip extends StatelessWidget {
const _DetailChip({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return ShadBadge.outline(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: theme.textTheme.small),
const SizedBox(height: 2),
Text(value, style: theme.textTheme.p),
],
),
),
);
}
}
class _FormFieldLabel extends StatelessWidget {
const _FormFieldLabel({required this.label, required this.child});
final String label;
final Widget child;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: theme.textTheme.small),
const SizedBox(height: 6),
child,
],
);
}
}
class _LineItemRow extends StatelessWidget {
const _LineItemRow({required this.draft, required this.onRemove});
final _LineItemDraft draft;
final VoidCallback? onRemove;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ShadInput(
controller: draft.product,
placeholder: const Text('제품명'),
),
),
const SizedBox(width: 12),
Expanded(
child: ShadInput(
controller: draft.manufacturer,
placeholder: const Text('제조사'),
),
),
const SizedBox(width: 12),
SizedBox(
width: 80,
child: ShadInput(
controller: draft.unit,
placeholder: const Text('단위'),
),
),
const SizedBox(width: 12),
SizedBox(
width: 100,
child: ShadInput(
controller: draft.quantity,
placeholder: const Text('수량'),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 12),
SizedBox(
width: 120,
child: ShadInput(
controller: draft.price,
placeholder: const Text('단가'),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 12),
Expanded(
child: ShadInput(
controller: draft.remark,
placeholder: const Text('비고'),
),
),
const SizedBox(width: 12),
ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: onRemove,
child: const Icon(LucideIcons.trash2, size: 16),
),
],
);
}
}
class _LineItemDraft {
_LineItemDraft._({
required this.product,
required this.manufacturer,
required this.unit,
required this.quantity,
required this.price,
required this.remark,
});
final TextEditingController product;
final TextEditingController manufacturer;
final TextEditingController unit;
final TextEditingController quantity;
final TextEditingController price;
final TextEditingController remark;
factory _LineItemDraft.empty() {
return _LineItemDraft._(
product: TextEditingController(),
manufacturer: TextEditingController(),
unit: TextEditingController(text: 'EA'),
quantity: TextEditingController(text: '0'),
price: TextEditingController(text: '0'),
remark: TextEditingController(),
);
}
factory _LineItemDraft.fromItem(InboundLineItem item) {
return _LineItemDraft._(
product: TextEditingController(text: item.product),
manufacturer: TextEditingController(text: item.manufacturer),
unit: TextEditingController(text: item.unit),
quantity: TextEditingController(text: '${item.quantity}'),
price: TextEditingController(text: item.price.toStringAsFixed(0)),
remark: TextEditingController(text: item.remark),
);
}
void dispose() {
product.dispose();
manufacturer.dispose();
unit.dispose();
quantity.dispose();
price.dispose();
remark.dispose();
}
}
class InboundRecord {
InboundRecord({
required this.number,
required this.transactionNumber,
required this.processedAt,
required this.warehouse,
required this.status,
required this.writer,
required this.remark,
required this.items,
});
final String number;
final String transactionNumber;
final DateTime processedAt;
final String warehouse;
final String status;
final String writer;
final String remark;
final List<InboundLineItem> items;
int get itemCount => items.length;
int get totalQuantity =>
items.fold<int>(0, (sum, item) => sum + item.quantity);
double get totalAmount =>
items.fold<double>(0, (sum, item) => sum + (item.price * item.quantity));
}
class InboundLineItem {
InboundLineItem({
required this.product,
required this.manufacturer,
required this.unit,
required this.quantity,
required this.price,
required this.remark,
});
final String product;
final String manufacturer;
final String unit;
final int quantity;
final double price;
final String remark;
}
final List<InboundRecord> _mockRecords = [
InboundRecord(
number: 'IN-20240301-001',
transactionNumber: 'TX-20240301-001',
processedAt: DateTime(2024, 3, 1),
warehouse: '서울 1창고',
status: '작성중',
writer: '홍길동',
remark: '-',
items: [
InboundLineItem(
product: 'XR-5000',
manufacturer: '슈퍼벤더',
unit: 'EA',
quantity: 40,
price: 120000,
remark: '',
),
InboundLineItem(
product: 'XR-5001',
manufacturer: '슈퍼벤더',
unit: 'EA',
quantity: 60,
price: 98000,
remark: '',
),
],
),
InboundRecord(
number: 'IN-20240305-002',
transactionNumber: 'TX-20240305-010',
processedAt: DateTime(2024, 3, 5),
warehouse: '부산 센터',
status: '승인대기',
writer: '김담당',
remark: '긴급 입고',
items: [
InboundLineItem(
product: 'Eco-200',
manufacturer: '그린텍',
unit: 'EA',
quantity: 25,
price: 145000,
remark: 'QC 필요',
),
InboundLineItem(
product: 'Eco-200B',
manufacturer: '그린텍',
unit: 'EA',
quantity: 10,
price: 160000,
remark: '',
),
],
),
InboundRecord(
number: 'IN-20240310-003',
transactionNumber: 'TX-20240310-004',
processedAt: DateTime(2024, 3, 10),
warehouse: '대전 물류',
status: '승인완료',
writer: '최검수',
remark: '완료',
items: [
InboundLineItem(
product: 'Delta-One',
manufacturer: '델타',
unit: 'SET',
quantity: 8,
price: 450000,
remark: '설치 일정 확인',
),
],
),
];

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../core/constants/app_sections.dart';
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final idController = TextEditingController();
final passwordController = TextEditingController();
bool rememberMe = false;
@override
void dispose() {
idController.dispose();
passwordController.dispose();
super.dispose();
}
void _handleSubmit() {
context.go(dashboardRoutePath);
}
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Scaffold(
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460),
child: Padding(
padding: const EdgeInsets.all(24),
child: ShadCard(
title: Text('Superport v2 로그인', style: theme.textTheme.h3),
description: Text(
'사번 또는 이메일과 비밀번호를 입력하여 대시보드로 이동합니다.',
style: theme.textTheme.muted,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ShadInput(
controller: idController,
placeholder: const Text('사번 또는 이메일'),
autofillHints: const [AutofillHints.username],
leading: const Icon(LucideIcons.user),
),
const SizedBox(height: 16),
ShadInput(
controller: passwordController,
placeholder: const Text('비밀번호'),
obscureText: true,
autofillHints: const [AutofillHints.password],
leading: const Icon(LucideIcons.lock),
),
const SizedBox(height: 12),
Row(
children: [
ShadSwitch(
value: rememberMe,
onChanged: (value) =>
setState(() => rememberMe = value),
),
const SizedBox(width: 12),
Text('자동 로그인', style: theme.textTheme.small),
],
),
const SizedBox(height: 24),
ShadButton(
onPressed: _handleSubmit,
child: const Text('로그인'),
),
],
),
),
),
),
),
);
}
}

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',
],
],
),
),
],
);
}
}

View File

@@ -0,0 +1,30 @@
import 'package:flutter/widgets.dart';
import '../../../../widgets/spec_page.dart';
class ReportingPage extends StatelessWidget {
const ReportingPage({super.key});
@override
Widget build(BuildContext context) {
return const SpecPage(
title: '보고서',
summary: '기간, 유형, 창고, 상태 조건으로 보고서를 조회하고 내보냅니다.',
sections: [
SpecSection(
title: '조건 입력',
items: [
'기간 [Date Range]',
'유형 [Dropdown]',
'창고 [Dropdown]',
'상태 [Dropdown]',
],
),
SpecSection(
title: '출력 옵션',
items: ['XLSX 다운로드 [Button]', 'PDF 다운로드 [Button]'],
),
],
);
}
}

View File

@@ -0,0 +1,25 @@
import 'package:flutter/widgets.dart';
import '../../../../../widgets/spec_page.dart';
class PostalSearchPage extends StatelessWidget {
const PostalSearchPage({super.key});
@override
Widget build(BuildContext context) {
return const SpecPage(
title: '우편번호 검색',
summary: '모달 기반 우편번호 검색 UI 구성을 정의합니다.',
sections: [
SpecSection(
title: '모달 구성',
items: [
'검색어 [Text] 입력 필드',
'결과 리스트: 우편번호 | 시도 | 시군구 | 도로명 | 건물번호',
'선택 시 호출 화면에 우편번호/주소 전달',
],
),
],
);
}
}

View File

@@ -0,0 +1,42 @@
// ignore_for_file: public_member_api_docs
import 'package:dio/dio.dart';
import 'package:get_it/get_it.dart';
import 'core/network/api_client.dart';
import 'core/network/interceptors/auth_interceptor.dart';
import 'features/masters/vendor/data/repositories/vendor_repository_remote.dart';
import 'features/masters/vendor/domain/repositories/vendor_repository.dart';
/// 전역 DI 컨테이너
final GetIt sl = GetIt.instance;
/// 의존성 등록(스켈레톤)
/// - Environment.initialize() 이후 호출하여 baseUrl/타임아웃 등을 주입한다.
Future<void> initInjection({required String baseUrl, Duration? connectTimeout, Duration? receiveTimeout}) async {
// Dio 기본 옵션 설정
final options = BaseOptions(
baseUrl: baseUrl,
connectTimeout: connectTimeout ?? const Duration(seconds: 15),
receiveTimeout: receiveTimeout ?? const Duration(seconds: 30),
headers: const {
'Accept': 'application/json',
},
);
final dio = Dio(options);
// 인터셉터 등록 (Auth 등)
dio.interceptors.add(AuthInterceptor());
// 개발용 로거는 필요 시 추가 (pretty_dio_logger 등)
// if (!kReleaseMode) { dio.interceptors.add(PrettyDioLogger(...)); }
// ApiClient 등록
sl.registerLazySingleton<ApiClient>(() => ApiClient(dio: dio));
// 리포지토리 등록 (예: 벤더)
sl.registerLazySingleton<VendorRepository>(
() => VendorRepositoryRemote(apiClient: sl<ApiClient>()),
);
}

41
lib/main.dart Normal file
View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'core/config/environment.dart';
import 'core/routing/app_router.dart';
import 'injection_container.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Environment.initialize();
await initInjection(baseUrl: Environment.baseUrl);
runApp(const SuperportApp());
}
class SuperportApp extends StatelessWidget {
const SuperportApp({super.key});
@override
Widget build(BuildContext context) {
return ShadApp.router(
title: 'Superport v2',
routerConfig: appRouter,
debugShowCheckedModeBanner: false,
supportedLocales: const [Locale('ko', 'KR'), Locale('en', 'US')],
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
theme: ShadThemeData(
colorScheme: const ShadSlateColorScheme.light(),
brightness: Brightness.light,
),
darkTheme: ShadThemeData(
colorScheme: const ShadSlateColorScheme.dark(),
brightness: Brightness.dark,
),
);
}
}

206
lib/widgets/app_shell.dart Normal file
View File

@@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../core/constants/app_sections.dart';
class AppShell extends StatelessWidget {
const AppShell({
super.key,
required this.child,
required this.currentLocation,
});
final Widget child;
final String currentLocation;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= 960;
if (isWide) {
return Scaffold(
appBar: AppBar(
title: const Text('Superport v2'),
actions: [
IconButton(
tooltip: '로그아웃',
icon: const Icon(LucideIcons.logOut),
onPressed: () => context.go(loginRoutePath),
),
],
),
body: Row(
children: [
_NavigationRail(currentLocation: currentLocation),
const VerticalDivider(width: 1),
Expanded(child: child),
],
),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Superport v2'),
actions: [
IconButton(
tooltip: '로그아웃',
icon: const Icon(LucideIcons.logOut),
onPressed: () => context.go(loginRoutePath),
),
],
),
drawer: Drawer(
child: SafeArea(
child: _NavigationList(
currentLocation: currentLocation,
onTap: (path) {
Navigator.of(context).pop();
context.go(path);
},
),
),
),
body: child,
);
},
);
}
}
class _NavigationRail extends StatelessWidget {
const _NavigationRail({required this.currentLocation});
final String currentLocation;
@override
Widget build(BuildContext context) {
final pages = allAppPages;
final selectedIndex = _selectedIndex(currentLocation, pages);
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
width: 104,
decoration: BoxDecoration(
border: Border(right: BorderSide(color: colorScheme.outlineVariant)),
),
child: Column(
children: [
const SizedBox(height: 24),
const FlutterLogo(size: 48),
const SizedBox(height: 24),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: pages.length,
itemBuilder: (context, index) {
final page = pages[index];
final isSelected = index == selectedIndex;
final textStyle = theme.textTheme.labelSmall?.copyWith(
color: isSelected
? colorScheme.primary
: colorScheme.onSurfaceVariant,
);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Material(
color: isSelected
? colorScheme.primary.withValues(alpha: 0.12)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () {
if (page.path != currentLocation) {
context.go(page.path);
}
},
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 8,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
page.icon,
size: 22,
color: isSelected
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 6),
Text(
page.label,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: textStyle,
),
],
),
),
),
),
);
},
),
),
],
),
);
}
}
class _NavigationList extends StatelessWidget {
const _NavigationList({required this.currentLocation, required this.onTap});
final String currentLocation;
final ValueChanged<String> onTap;
@override
Widget build(BuildContext context) {
final pages = allAppPages;
final selectedIndex = _selectedIndex(currentLocation, pages);
return ListView.builder(
itemCount: pages.length,
itemBuilder: (context, index) {
final page = pages[index];
final selected = index == selectedIndex;
return ListTile(
leading: Icon(page.icon),
title: Text(page.label),
subtitle: Text(
page.summary,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
selected: selected,
selectedColor: Theme.of(context).colorScheme.primary,
onTap: () => onTap(page.path),
);
},
);
}
}
int _selectedIndex(String location, List<AppPageDescriptor> pages) {
final normalized = location.toLowerCase();
final exact = pages.indexWhere(
(page) => normalized == page.path.toLowerCase(),
);
if (exact != -1) {
return exact;
}
final prefix = pages.indexWhere(
(page) => normalized.startsWith(page.path.toLowerCase()),
);
return prefix == -1 ? 0 : prefix;
}

174
lib/widgets/spec_page.dart Normal file
View File

@@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
class SpecTable {
const SpecTable({
required this.columns,
required this.rows,
this.columnWidth,
});
final List<String> columns;
final List<List<String>> rows;
final double? columnWidth;
}
class SpecSection {
const SpecSection({
required this.title,
this.items = const <String>[],
this.description,
this.table,
});
final String title;
final List<String> items;
final String? description;
final SpecTable? table;
}
class SpecPage extends StatelessWidget {
const SpecPage({
super.key,
required this.title,
required this.summary,
required this.sections,
this.trailing,
});
final String title;
final String summary;
final List<SpecSection> sections;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return SelectionArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(32),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.h2),
const SizedBox(height: 12),
Text(summary, style: theme.textTheme.lead),
],
),
),
if (trailing != null) ...[
const SizedBox(width: 24),
trailing!,
],
],
),
const SizedBox(height: 32),
...sections.map(
(section) => Padding(
padding: const EdgeInsets.only(bottom: 24),
child: ShadCard(
title: Text(
section.title,
style: theme.textTheme.h3.copyWith(
color: theme.colorScheme.foreground,
),
),
description: section.description == null
? null
: Text(
section.description!,
style: theme.textTheme.muted,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (section.items.isNotEmpty) ...[
for (final item in section.items)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 6),
child: Icon(
LucideIcons.dot,
size: 10,
color:
theme.colorScheme.mutedForeground,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
item,
style: theme.textTheme.p,
),
),
],
),
),
],
if (section.table != null) ...[
if (section.items.isNotEmpty)
const SizedBox(height: 16),
_SpecTableView(table: section.table!),
],
],
),
),
),
),
],
),
),
),
),
);
}
}
class _SpecTableView extends StatelessWidget {
const _SpecTableView({required this.table});
final SpecTable table;
@override
Widget build(BuildContext context) {
final headerCells = table.columns
.map((column) => ShadTableCell.header(child: Text(column)))
.toList(growable: false);
final rowCells = table.rows
.map(
(row) => row
.map((cell) => ShadTableCell(child: Text(cell)))
.toList(growable: false),
)
.toList(growable: false);
final rowCount = table.rows.length;
final baseHeight = 56.0; // default row height with some breathing room
final height = (rowCount + 1) * baseHeight;
return SizedBox(
height: height,
child: ShadTable.list(
header: headerCells,
children: rowCells,
columnSpanExtent: (index) =>
FixedTableSpanExtent(table.columnWidth ?? 160),
),
);
}
}