환경 초기화 및 벤더 리포지토리 스켈레톤 도입
This commit is contained in:
@@ -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',
|
||||
'-',
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
'-',
|
||||
'-',
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
'-',
|
||||
'-',
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 카드',
|
||||
'최근 트랜잭션 리스트: 번호 · 일자 · 유형 · 상태 · 작성자',
|
||||
'내 결재 요청/대기 건 알림 패널',
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
1125
lib/features/inventory/rental/presentation/pages/rental_page.dart
Normal file
1125
lib/features/inventory/rental/presentation/pages/rental_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
88
lib/features/login/presentation/pages/login_page.dart
Normal file
88
lib/features/login/presentation/pages/login_page.dart
Normal 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('로그인'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
'-',
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
52
lib/features/masters/menu/presentation/pages/menu_page.dart
Normal file
52
lib/features/masters/menu/presentation/pages/menu_page.dart
Normal 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',
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
60
lib/features/masters/user/presentation/pages/user_page.dart
Normal file
60
lib/features/masters/user/presentation/pages/user_page.dart
Normal 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',
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
80
lib/features/masters/vendor/data/dtos/vendor_dto.dart
vendored
Normal file
80
lib/features/masters/vendor/data/dtos/vendor_dto.dart
vendored
Normal 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;
|
||||
}
|
||||
|
||||
70
lib/features/masters/vendor/data/repositories/vendor_repository_remote.dart
vendored
Normal file
70
lib/features/masters/vendor/data/repositories/vendor_repository_remote.dart
vendored
Normal 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');
|
||||
}
|
||||
}
|
||||
|
||||
61
lib/features/masters/vendor/domain/entities/vendor.dart
vendored
Normal file
61
lib/features/masters/vendor/domain/entities/vendor.dart
vendored
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
27
lib/features/masters/vendor/domain/repositories/vendor_repository.dart
vendored
Normal file
27
lib/features/masters/vendor/domain/repositories/vendor_repository.dart
vendored
Normal 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);
|
||||
}
|
||||
|
||||
175
lib/features/masters/vendor/presentation/pages/vendor_page.dart
vendored
Normal file
175
lib/features/masters/vendor/presentation/pages/vendor_page.dart
vendored
Normal 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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]'],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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] 입력 필드',
|
||||
'결과 리스트: 우편번호 | 시도 | 시군구 | 도로명 | 건물번호',
|
||||
'선택 시 호출 화면에 우편번호/주소 전달',
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user