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

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,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