API v4 계약 반영하고 보고서·입출고 화면 실연동 강화

This commit is contained in:
JiWoong Sul
2025-10-16 14:57:07 +09:00
parent 7e0f7b1c55
commit d5c99627db
34 changed files with 1767 additions and 327 deletions

View File

@@ -0,0 +1,15 @@
import 'dart:typed_data';
import 'file_saver_stub.dart' if (dart.library.html) 'file_saver_web.dart';
/// 바이트 데이터를 로컬 파일로 저장한다.
Future<void> saveFileBytes({
required Uint8List bytes,
required String filename,
required String mimeType,
}) async {
assert(filename.isNotEmpty, 'filename은 비어 있을 수 없습니다.');
assert(bytes.isNotEmpty, 'bytes는 비어 있을 수 없습니다.');
await saveFileBytesImpl(bytes: bytes, filename: filename, mimeType: mimeType);
}

View File

@@ -0,0 +1,10 @@
import 'dart:typed_data';
/// 웹 외 플랫폼에서 파일 저장이 호출되면 예외를 발생시킨다.
Future<void> saveFileBytesImpl({
required Uint8List bytes,
required String filename,
required String mimeType,
}) async {
throw UnsupportedError('현재 플랫폼에서는 파일 저장을 지원하지 않습니다.');
}

View File

@@ -0,0 +1,20 @@
import 'dart:typed_data';
import 'package:web/web.dart' as web;
/// 웹 환경에서 Anchor 요소를 사용해 파일 저장을 트리거한다.
Future<void> saveFileBytesImpl({
required Uint8List bytes,
required String filename,
required String mimeType,
}) async {
final dataUrl = Uri.dataFromBytes(bytes, mimeType: mimeType).toString();
final anchor = web.document.createElement('a') as web.HTMLAnchorElement
..href = dataUrl
..download = filename;
anchor.style.display = 'none';
web.document.body?.append(anchor);
anchor.click();
anchor.remove();
}

View File

@@ -214,7 +214,9 @@ class ApprovalStepDto {
(json['approver'] as Map<String, dynamic>? ?? const {}),
),
status: ApprovalStatusDto.fromJson(
(json['status'] as Map<String, dynamic>? ?? const {}),
(json['status'] as Map<String, dynamic>? ??
json['step_status'] as Map<String, dynamic>? ??
const {}),
),
assignedAt: _parseDate(json['assigned_at']) ?? DateTime.now(),
decidedAt: _parseDate(json['decided_at']),
@@ -263,7 +265,12 @@ class ApprovalHistoryDto {
return ApprovalHistoryDto(
id: json['id'] as int?,
action: ApprovalActionDto.fromJson(
(json['action'] as Map<String, dynamic>? ?? const {}),
json['action'] is Map<String, dynamic>
? json['action'] as Map<String, dynamic>
: {
'id': json['approval_action_id'],
'name': json['approval_action_name'],
},
),
fromStatus: json['from_status'] is Map<String, dynamic>
? ApprovalStatusDto.fromJson(

View File

@@ -175,22 +175,199 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
Map<String, dynamic> body,
) {
final data = body['data'];
if (data is Map<String, dynamic>) {
if (data['approval'] is Map<String, dynamic>) {
return data['approval'] as Map<String, dynamic>;
final dataMap = data is Map<String, dynamic> ? data : null;
Map<String, dynamic>? approval = _selectApprovalPayload(dataMap);
approval ??= _selectApprovalPayload(body);
if (approval == null) {
return null;
}
final merged = Map<String, dynamic>.from(approval);
if (dataMap != null) {
final steps = _mergeStepsPayload(
existing: merged['steps'],
data: dataMap,
);
if (steps != null) {
merged['steps'] = steps;
}
if (data['approval_data'] is Map<String, dynamic>) {
return data['approval_data'] as Map<String, dynamic>;
}
final hasStatus =
data.containsKey('status') || data.containsKey('approval_status');
if (data.containsKey('approval_no') && hasStatus) {
return data;
final histories = _mergeHistoriesPayload(
existing: merged['histories'],
data: dataMap,
);
if (histories != null) {
merged['histories'] = histories;
}
}
if (body['approval'] is Map<String, dynamic>) {
return body['approval'] as Map<String, dynamic>;
return merged;
}
Map<String, dynamic>? _selectApprovalPayload(Map<String, dynamic>? source) {
if (source == null) {
return null;
}
if (source['approval'] is Map<String, dynamic>) {
return Map<String, dynamic>.from(
source['approval'] as Map<String, dynamic>,
);
}
if (source['approval_data'] is Map<String, dynamic>) {
return Map<String, dynamic>.from(
source['approval_data'] as Map<String, dynamic>,
);
}
final hasStatus =
source.containsKey('status') || source.containsKey('approval_status');
if (source.containsKey('approval_no') && hasStatus) {
return Map<String, dynamic>.from(source);
}
if (source['approval'] == null && source['data'] is Map<String, dynamic>) {
return _selectApprovalPayload(source['data'] as Map<String, dynamic>);
}
return null;
}
List<Map<String, dynamic>>? _mergeStepsPayload({
required dynamic existing,
required Map<String, dynamic> data,
}) {
final steps = <Map<String, dynamic>>[];
void upsert(Map<String, dynamic> step) {
final id = step['id'] as int?;
final order = step['step_order'] as int?;
final index = steps.indexWhere((element) {
final elementId = element['id'] as int?;
if (elementId != null && id != null) {
return elementId == id;
}
if (order != null) {
return element['step_order'] == order;
}
return false;
});
final copy = Map<String, dynamic>.from(step);
if (index >= 0) {
steps[index] = copy;
} else {
steps.add(copy);
}
}
if (existing is List) {
for (final item in existing) {
if (item is Map<String, dynamic>) {
upsert(item);
}
}
}
final responseSteps = data['steps'];
if (responseSteps is List) {
for (final item in responseSteps) {
if (item is Map<String, dynamic>) {
upsert(item);
}
}
}
if (data['step'] is Map<String, dynamic>) {
upsert(data['step'] as Map<String, dynamic>);
}
if (data['next_step'] is Map<String, dynamic>) {
upsert(data['next_step'] as Map<String, dynamic>);
}
if (steps.isEmpty) {
return existing is List
? existing
.whereType<Map<String, dynamic>>()
.map((step) => Map<String, dynamic>.from(step))
.toList()
: null;
}
steps.sort((a, b) {
final orderA = a['step_order'] as int? ?? 0;
final orderB = b['step_order'] as int? ?? 0;
return orderA.compareTo(orderB);
});
return steps;
}
List<Map<String, dynamic>>? _mergeHistoriesPayload({
required dynamic existing,
required Map<String, dynamic> data,
}) {
final histories = <Map<String, dynamic>>[];
void append(Map<String, dynamic> history) {
histories.add(Map<String, dynamic>.from(history));
}
if (existing is List) {
for (final item in existing) {
if (item is Map<String, dynamic>) {
append(item);
}
}
}
final responseHistories = data['histories'];
if (responseHistories is List) {
for (final item in responseHistories) {
if (item is Map<String, dynamic>) {
append(item);
}
}
}
if (data['history'] is Map<String, dynamic>) {
append(data['history'] as Map<String, dynamic>);
}
if (histories.isEmpty) {
return existing is List
? existing
.whereType<Map<String, dynamic>>()
.map((history) => Map<String, dynamic>.from(history))
.toList()
: null;
}
DateTime? parseTime(Map<String, dynamic> json) {
String? read(dynamic value) {
if (value is String && value.trim().isNotEmpty) {
return value.trim();
}
return null;
}
final raw =
read(json['action_at']) ??
read(json['created_at']) ??
read(json['updated_at']);
if (raw == null) {
return null;
}
return DateTime.tryParse(raw);
}
histories.sort((a, b) {
final timeA = parseTime(a);
final timeB = parseTime(b);
if (timeA == null && timeB == null) {
return 0;
}
if (timeA == null) {
return 1;
}
if (timeB == null) {
return -1;
}
return timeA.compareTo(timeB);
});
return histories;
}
}

View File

@@ -31,10 +31,8 @@ class ApprovalHistoryRepositoryRemote implements ApprovalHistoryRepository {
query: {
'page': page,
'page_size': pageSize,
if (query != null && query.isNotEmpty) 'q': query,
if (action != null && action.isNotEmpty) 'action': action,
if (from != null) 'from': from.toIso8601String(),
if (to != null) 'to': to.toIso8601String(),
if (from != null) 'action_from': from.toIso8601String(),
if (to != null) 'action_to': to.toIso8601String(),
},
options: Options(responseType: ResponseType.json),
);

View File

@@ -1289,8 +1289,12 @@ class _InboundPageState extends State<InboundPage> {
);
final remarkController = TextEditingController(text: initial?.remark ?? '');
final transactionNumberController = TextEditingController(
text: initial?.transactionNumber ?? '저장 시 자동 생성',
text: initial?.transactionNumber ?? '',
);
final approvalNumberController = TextEditingController(
text: initial?.raw?.approval?.approvalNo ?? '',
);
final approvalNoteController = TextEditingController();
final transactionTypeValue =
initial?.transactionType ??
_transactionTypeLookup?.name ??
@@ -1311,6 +1315,8 @@ class _InboundPageState extends State<InboundPage> {
};
String? writerError;
String? transactionNumberError;
String? approvalNumberError;
String? warehouseError;
String? statusError;
String? headerNotice;
@@ -1339,12 +1345,18 @@ class _InboundPageState extends State<InboundPage> {
writerController: writerController,
writerSelection: writerSelection,
requireWriterSelection: initial == null,
transactionNumberController: transactionNumberController,
transactionNumberRequired: initial == null,
approvalNumberController: approvalNumberController,
approvalNumberRequired: initial == null,
warehouseSelection: warehouseSelection,
statusValue: statusValue.value,
drafts: drafts,
lineErrors: lineErrors,
);
writerError = validationResult.writerError;
transactionNumberError = validationResult.transactionNumberError;
approvalNumberError = validationResult.approvalNumberError;
warehouseError = validationResult.warehouseError;
statusError = validationResult.statusError;
headerNotice = validationResult.headerNotice;
@@ -1388,6 +1400,9 @@ class _InboundPageState extends State<InboundPage> {
final remarkText = remarkController.text.trim();
final remarkValue = remarkText.isEmpty ? null : remarkText;
final transactionNoValue = transactionNumberController.text.trim();
final approvalNoValue = approvalNumberController.text.trim();
final approvalNoteValue = approvalNoteController.text.trim();
final transactionId = initial?.id;
final initialRecord = initial;
@@ -1475,6 +1490,7 @@ class _InboundPageState extends State<InboundPage> {
.toList(growable: false);
final created = await controller.createTransaction(
StockTransactionCreateInput(
transactionNo: transactionNoValue,
transactionTypeId: transactionTypeLookup.id,
transactionStatusId: statusItem.id,
warehouseId: warehouseId,
@@ -1482,6 +1498,11 @@ class _InboundPageState extends State<InboundPage> {
createdById: createdById,
note: remarkValue,
lines: createLines,
approval: StockTransactionApprovalInput(
approvalNo: approvalNoValue,
requestedById: createdById,
note: approvalNoteValue.isEmpty ? null : approvalNoteValue,
),
),
);
result = created;
@@ -1635,10 +1656,41 @@ class _InboundPageState extends State<InboundPage> {
width: 240,
child: SuperportFormField(
label: '트랜잭션번호',
required: true,
errorText: transactionNumberError,
child: ShadInput(
controller: transactionNumberController,
readOnly: true,
enabled: false,
readOnly: initial != null,
enabled: initial == null,
placeholder: const Text('예: IN-2024-0001'),
onChanged: (_) {
if (transactionNumberError != null) {
setState(() {
transactionNumberError = null;
});
}
},
),
),
),
SizedBox(
width: 240,
child: SuperportFormField(
label: '결재번호',
required: true,
errorText: approvalNumberError,
child: ShadInput(
controller: approvalNumberController,
readOnly: initial != null,
enabled: initial == null,
placeholder: const Text('예: APP-2024-0001'),
onChanged: (_) {
if (approvalNumberError != null) {
setState(() {
approvalNumberError = null;
});
}
},
),
),
),
@@ -1680,6 +1732,16 @@ class _InboundPageState extends State<InboundPage> {
),
),
),
SizedBox(
width: 500,
child: SuperportFormField(
label: '결재 메모',
child: ShadInput(
controller: approvalNoteController,
maxLines: 2,
),
),
),
SizedBox(
width: 500,
child: SuperportFormField(
@@ -1802,6 +1864,8 @@ class _InboundPageState extends State<InboundPage> {
writerController.dispose();
remarkController.dispose();
transactionNumberController.dispose();
approvalNumberController.dispose();
approvalNoteController.dispose();
transactionTypeController.dispose();
processedAt.dispose();
@@ -2346,6 +2410,10 @@ _InboundFormValidation _validateInboundForm({
required TextEditingController writerController,
required InventoryEmployeeSuggestion? writerSelection,
required bool requireWriterSelection,
required TextEditingController transactionNumberController,
required bool transactionNumberRequired,
required TextEditingController approvalNumberController,
required bool approvalNumberRequired,
required InventoryWarehouseOption? warehouseSelection,
required String statusValue,
required List<_LineItemDraft> drafts,
@@ -2353,6 +2421,8 @@ _InboundFormValidation _validateInboundForm({
}) {
var isValid = true;
String? writerError;
String? transactionNumberError;
String? approvalNumberError;
String? warehouseError;
String? statusError;
String? headerNotice;
@@ -2368,6 +2438,18 @@ _InboundFormValidation _validateInboundForm({
isValid = false;
}
final transactionNumber = transactionNumberController.text.trim();
if (transactionNumberRequired && transactionNumber.isEmpty) {
transactionNumberError = '거래번호를 입력하세요.';
isValid = false;
}
final approvalNumber = approvalNumberController.text.trim();
if (approvalNumberRequired && approvalNumber.isEmpty) {
approvalNumberError = '결재번호를 입력하세요.';
isValid = false;
}
if (warehouseSelection == null) {
warehouseError = '창고를 선택하세요.';
isValid = false;
@@ -2426,6 +2508,8 @@ _InboundFormValidation _validateInboundForm({
return _InboundFormValidation(
isValid: isValid,
writerError: writerError,
transactionNumberError: transactionNumberError,
approvalNumberError: approvalNumberError,
warehouseError: warehouseError,
statusError: statusError,
headerNotice: headerNotice,
@@ -2441,6 +2525,8 @@ class _InboundFormValidation {
const _InboundFormValidation({
required this.isValid,
this.writerError,
this.transactionNumberError,
this.approvalNumberError,
this.warehouseError,
this.statusError,
this.headerNotice,
@@ -2448,6 +2534,8 @@ class _InboundFormValidation {
final bool isValid;
final String? writerError;
final String? transactionNumberError;
final String? approvalNumberError;
final String? warehouseError;
final String? statusError;
final String? headerNotice;

View File

@@ -1435,6 +1435,13 @@ class _OutboundPageState extends State<OutboundPage> {
final transactionTypeController = TextEditingController(
text: transactionTypeValue,
);
final transactionNumberController = TextEditingController(
text: initial?.transactionNumber ?? '',
);
final approvalNumberController = TextEditingController(
text: initial?.raw?.approval?.approvalNo ?? '',
);
final approvalNoteController = TextEditingController();
final drafts =
initial?.items
@@ -1448,6 +1455,8 @@ class _OutboundPageState extends State<OutboundPage> {
};
String? writerError;
String? transactionNumberError;
String? approvalNumberError;
String? customerError;
String? warehouseError;
String? statusError;
@@ -1477,6 +1486,10 @@ class _OutboundPageState extends State<OutboundPage> {
writerController: writerController,
writerSelection: writerSelection,
requireWriterSelection: initial == null,
transactionNumberController: transactionNumberController,
transactionNumberRequired: initial == null,
approvalNumberController: approvalNumberController,
approvalNumberRequired: initial == null,
warehouseSelection: warehouseSelection,
statusValue: statusValue.value,
selectedCustomers: customerSelection
@@ -1487,6 +1500,8 @@ class _OutboundPageState extends State<OutboundPage> {
);
writerError = validation.writerError;
transactionNumberError = validation.transactionNumberError;
approvalNumberError = validation.approvalNumberError;
customerError = validation.customerError;
warehouseError = validation.warehouseError;
statusError = validation.statusError;
@@ -1531,6 +1546,9 @@ class _OutboundPageState extends State<OutboundPage> {
final remarkText = remarkController.text.trim();
final remarkValue = remarkText.isEmpty ? null : remarkText;
final transactionNoValue = transactionNumberController.text.trim();
final approvalNoValue = approvalNumberController.text.trim();
final approvalNoteValue = approvalNoteController.text.trim();
final transactionId = initial?.id;
final lineDrafts = <TransactionLineDraft>[];
@@ -1656,6 +1674,7 @@ class _OutboundPageState extends State<OutboundPage> {
final created = await controller.createTransaction(
StockTransactionCreateInput(
transactionNo: transactionNoValue,
transactionTypeId: transactionTypeLookup.id,
transactionStatusId: statusItem.id,
warehouseId: warehouseId,
@@ -1664,6 +1683,11 @@ class _OutboundPageState extends State<OutboundPage> {
note: remarkValue,
lines: createLines,
customers: createCustomers,
approval: StockTransactionApprovalInput(
approvalNo: approvalNoValue,
requestedById: createdById,
note: approvalNoteValue.isEmpty ? null : approvalNoteValue,
),
),
);
result = created;
@@ -1808,6 +1832,48 @@ class _OutboundPageState extends State<OutboundPage> {
),
),
),
SizedBox(
width: 240,
child: SuperportFormField(
label: '트랜잭션번호',
required: true,
errorText: transactionNumberError,
child: ShadInput(
controller: transactionNumberController,
readOnly: initial != null,
enabled: initial == null,
placeholder: const Text('예: OUT-2024-0001'),
onChanged: (_) {
if (transactionNumberError != null) {
setState(() {
transactionNumberError = null;
});
}
},
),
),
),
SizedBox(
width: 240,
child: SuperportFormField(
label: '결재번호',
required: true,
errorText: approvalNumberError,
child: ShadInput(
controller: approvalNumberController,
readOnly: initial != null,
enabled: initial == null,
placeholder: const Text('예: APP-2024-0001'),
onChanged: (_) {
if (approvalNumberError != null) {
setState(() {
approvalNumberError = null;
});
}
},
),
),
),
SizedBox(
width: 240,
child: SuperportFormField(
@@ -1846,6 +1912,16 @@ class _OutboundPageState extends State<OutboundPage> {
),
),
),
SizedBox(
width: 500,
child: SuperportFormField(
label: '결재 메모',
child: ShadInput(
controller: approvalNoteController,
maxLines: 2,
),
),
),
SizedBox(
width: 360,
child: SuperportFormField(
@@ -2012,6 +2088,9 @@ class _OutboundPageState extends State<OutboundPage> {
writerController.dispose();
remarkController.dispose();
transactionTypeController.dispose();
transactionNumberController.dispose();
approvalNumberController.dispose();
approvalNoteController.dispose();
processedAt.dispose();
return result;
@@ -2460,6 +2539,10 @@ _OutboundFormValidation _validateOutboundForm({
required TextEditingController writerController,
required InventoryEmployeeSuggestion? writerSelection,
required bool requireWriterSelection,
required TextEditingController transactionNumberController,
required bool transactionNumberRequired,
required TextEditingController approvalNumberController,
required bool approvalNumberRequired,
required InventoryWarehouseOption? warehouseSelection,
required String statusValue,
required List<InventoryCustomerOption> selectedCustomers,
@@ -2468,6 +2551,8 @@ _OutboundFormValidation _validateOutboundForm({
}) {
var isValid = true;
String? writerError;
String? transactionNumberError;
String? approvalNumberError;
String? customerError;
String? warehouseError;
String? statusError;
@@ -2484,6 +2569,18 @@ _OutboundFormValidation _validateOutboundForm({
isValid = false;
}
final transactionNumber = transactionNumberController.text.trim();
if (transactionNumberRequired && transactionNumber.isEmpty) {
transactionNumberError = '거래번호를 입력하세요.';
isValid = false;
}
final approvalNumber = approvalNumberController.text.trim();
if (approvalNumberRequired && approvalNumber.isEmpty) {
approvalNumberError = '결재번호를 입력하세요.';
isValid = false;
}
if (warehouseSelection == null) {
warehouseError = '창고를 선택하세요.';
isValid = false;
@@ -2547,6 +2644,8 @@ _OutboundFormValidation _validateOutboundForm({
return _OutboundFormValidation(
isValid: isValid,
writerError: writerError,
transactionNumberError: transactionNumberError,
approvalNumberError: approvalNumberError,
customerError: customerError,
warehouseError: warehouseError,
statusError: statusError,
@@ -2558,6 +2657,8 @@ class _OutboundFormValidation {
const _OutboundFormValidation({
required this.isValid,
this.writerError,
this.transactionNumberError,
this.approvalNumberError,
this.customerError,
this.warehouseError,
this.statusError,
@@ -2566,6 +2667,8 @@ class _OutboundFormValidation {
final bool isValid;
final String? writerError;
final String? transactionNumberError;
final String? approvalNumberError;
final String? customerError;
final String? warehouseError;
final String? statusError;

View File

@@ -1411,6 +1411,13 @@ class _RentalPageState extends State<RentalPage> {
final transactionTypeController = TextEditingController(
text: _transactionTypeForRental(rentalTypeValue.value),
);
final transactionNumberController = TextEditingController(
text: initial?.transactionNumber ?? '',
);
final approvalNumberController = TextEditingController(
text: initial?.raw?.approval?.approvalNo ?? '',
);
final approvalNoteController = TextEditingController();
final drafts =
initial?.items
@@ -1421,6 +1428,8 @@ class _RentalPageState extends State<RentalPage> {
RentalRecord? result;
String? writerError;
String? transactionNumberError;
String? approvalNumberError;
String? customerError;
String? warehouseError;
String? statusError;
@@ -1452,6 +1461,10 @@ class _RentalPageState extends State<RentalPage> {
writerController: writerController,
writerSelection: writerSelection,
requireWriterSelection: initial == null,
transactionNumberController: transactionNumberController,
transactionNumberRequired: initial == null,
approvalNumberController: approvalNumberController,
approvalNumberRequired: initial == null,
warehouseSelection: warehouseSelection,
statusValue: statusValue.value,
selectedCustomers: customerSelection
@@ -1462,6 +1475,8 @@ class _RentalPageState extends State<RentalPage> {
);
writerError = validation.writerError;
transactionNumberError = validation.transactionNumberError;
approvalNumberError = validation.approvalNumberError;
customerError = validation.customerError;
warehouseError = validation.warehouseError;
statusError = validation.statusError;
@@ -1507,6 +1522,9 @@ class _RentalPageState extends State<RentalPage> {
final remarkText = remarkController.text.trim();
final remarkValue = remarkText.isEmpty ? null : remarkText;
final transactionNoValue = transactionNumberController.text.trim();
final approvalNoValue = approvalNumberController.text.trim();
final approvalNoteValue = approvalNoteController.text.trim();
final transactionId = initial?.id;
final initialRecord = initial;
@@ -1633,6 +1651,7 @@ class _RentalPageState extends State<RentalPage> {
final transactionTypeId = selectedLookup.id;
final created = await controller.createTransaction(
StockTransactionCreateInput(
transactionNo: transactionNoValue,
transactionTypeId: transactionTypeId,
transactionStatusId: statusItem.id,
warehouseId: warehouseId,
@@ -1642,6 +1661,11 @@ class _RentalPageState extends State<RentalPage> {
expectedReturnDate: returnDue.value,
lines: createLines,
customers: createCustomers,
approval: StockTransactionApprovalInput(
approvalNo: approvalNoValue,
requestedById: createdById,
note: approvalNoteValue.isEmpty ? null : approvalNoteValue,
),
),
);
result = created;
@@ -1815,6 +1839,74 @@ class _RentalPageState extends State<RentalPage> {
),
),
),
SizedBox(
width: 240,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_FormFieldLabel(
label: '트랜잭션번호',
child: ShadInput(
controller: transactionNumberController,
readOnly: initial != null,
enabled: initial == null,
placeholder: const Text('예: RENT-2024-0001'),
onChanged: (_) {
if (transactionNumberError != null) {
setState(() {
transactionNumberError = null;
});
}
},
),
),
if (transactionNumberError != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
transactionNumberError!,
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.destructive,
),
),
),
],
),
),
SizedBox(
width: 240,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_FormFieldLabel(
label: '결재번호',
child: ShadInput(
controller: approvalNumberController,
readOnly: initial != null,
enabled: initial == null,
placeholder: const Text('예: APP-2024-0001'),
onChanged: (_) {
if (approvalNumberError != null) {
setState(() {
approvalNumberError = null;
});
}
},
),
),
if (approvalNumberError != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
approvalNumberError!,
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.destructive,
),
),
),
],
),
),
SizedBox(
width: 360,
child: _FormFieldLabel(
@@ -1939,6 +2031,16 @@ class _RentalPageState extends State<RentalPage> {
),
),
),
SizedBox(
width: 500,
child: _FormFieldLabel(
label: '결재 메모',
child: ShadInput(
controller: approvalNoteController,
maxLines: 2,
),
),
),
SizedBox(
width: 500,
child: _FormFieldLabel(
@@ -2068,6 +2170,9 @@ class _RentalPageState extends State<RentalPage> {
writerController.dispose();
remarkController.dispose();
transactionTypeController.dispose();
transactionNumberController.dispose();
approvalNumberController.dispose();
approvalNoteController.dispose();
processedAt.dispose();
returnDue.dispose();
@@ -2543,6 +2648,10 @@ _RentalFormValidation _validateRentalForm({
required TextEditingController writerController,
required InventoryEmployeeSuggestion? writerSelection,
required bool requireWriterSelection,
required TextEditingController transactionNumberController,
required bool transactionNumberRequired,
required TextEditingController approvalNumberController,
required bool approvalNumberRequired,
required InventoryWarehouseOption? warehouseSelection,
required String statusValue,
required List<InventoryCustomerOption> selectedCustomers,
@@ -2551,6 +2660,8 @@ _RentalFormValidation _validateRentalForm({
}) {
var isValid = true;
String? writerError;
String? transactionNumberError;
String? approvalNumberError;
String? customerError;
String? warehouseError;
String? statusError;
@@ -2567,6 +2678,18 @@ _RentalFormValidation _validateRentalForm({
isValid = false;
}
final transactionNumber = transactionNumberController.text.trim();
if (transactionNumberRequired && transactionNumber.isEmpty) {
transactionNumberError = '거래번호를 입력하세요.';
isValid = false;
}
final approvalNumber = approvalNumberController.text.trim();
if (approvalNumberRequired && approvalNumber.isEmpty) {
approvalNumberError = '결재번호를 입력하세요.';
isValid = false;
}
if (warehouseSelection == null) {
warehouseError = '창고를 선택하세요.';
isValid = false;
@@ -2630,6 +2753,8 @@ _RentalFormValidation _validateRentalForm({
return _RentalFormValidation(
isValid: isValid,
writerError: writerError,
transactionNumberError: transactionNumberError,
approvalNumberError: approvalNumberError,
customerError: customerError,
warehouseError: warehouseError,
statusError: statusError,
@@ -2641,6 +2766,8 @@ class _RentalFormValidation {
const _RentalFormValidation({
required this.isValid,
this.writerError,
this.transactionNumberError,
this.approvalNumberError,
this.customerError,
this.warehouseError,
this.statusError,
@@ -2649,6 +2776,8 @@ class _RentalFormValidation {
final bool isValid;
final String? writerError;
final String? transactionNumberError;
final String? approvalNumberError;
final String? customerError;
final String? warehouseError;
final String? statusError;

View File

@@ -37,6 +37,7 @@ class InventoryEmployeeAutocompleteField extends StatefulWidget {
required this.onSuggestionSelected,
this.onChanged,
this.enabled = true,
this.placeholder = '작성자 이름 또는 사번 검색',
});
final TextEditingController controller;
@@ -44,6 +45,7 @@ class InventoryEmployeeAutocompleteField extends StatefulWidget {
final ValueChanged<InventoryEmployeeSuggestion?> onSuggestionSelected;
final VoidCallback? onChanged;
final bool enabled;
final String placeholder;
@override
State<InventoryEmployeeAutocompleteField> createState() =>
@@ -193,7 +195,7 @@ class _InventoryEmployeeAutocompleteFieldState
controller: textController,
focusNode: focusNode,
enabled: widget.enabled,
placeholder: const Text('작성자 이름 또는 사번 검색'),
placeholder: Text(widget.placeholder),
onChanged: (_) => widget.onChanged?.call(),
onSubmitted: (_) => onFieldSubmitted(),
);

View File

@@ -84,6 +84,7 @@ class StockTransactionRepositoryRemote implements StockTransactionRepository {
Future<StockTransaction> submit(int id) async {
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/$id/submit',
data: {'id': id},
options: Options(responseType: ResponseType.json),
);
return _parseSingle(response.data);
@@ -93,6 +94,7 @@ class StockTransactionRepositoryRemote implements StockTransactionRepository {
Future<StockTransaction> complete(int id) async {
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/$id/complete',
data: {'id': id},
options: Options(responseType: ResponseType.json),
);
return _parseSingle(response.data);
@@ -102,6 +104,7 @@ class StockTransactionRepositoryRemote implements StockTransactionRepository {
Future<StockTransaction> approve(int id) async {
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/$id/approve',
data: {'id': id},
options: Options(responseType: ResponseType.json),
);
return _parseSingle(response.data);
@@ -111,6 +114,7 @@ class StockTransactionRepositoryRemote implements StockTransactionRepository {
Future<StockTransaction> reject(int id) async {
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/$id/reject',
data: {'id': id},
options: Options(responseType: ResponseType.json),
);
return _parseSingle(response.data);
@@ -120,6 +124,7 @@ class StockTransactionRepositoryRemote implements StockTransactionRepository {
Future<StockTransaction> cancel(int id) async {
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/$id/cancel',
data: {'id': id},
options: Options(responseType: ResponseType.json),
);
return _parseSingle(response.data);

View File

@@ -1,11 +1,8 @@
import 'package:dio/dio.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/core/network/api_routes.dart';
import '../../domain/entities/stock_transaction.dart';
import '../../domain/entities/stock_transaction_input.dart';
import '../../domain/repositories/stock_transaction_repository.dart';
import '../dtos/stock_transaction_dto.dart';
/// 재고 트랜잭션 고객 연결 API를 호출하는 원격 저장소 구현체.
class TransactionCustomerRepositoryRemote
@@ -19,11 +16,11 @@ class TransactionCustomerRepositoryRemote
static const _customerPath = '${ApiRoutes.apiV1}/transaction-customers';
@override
Future<List<StockTransactionCustomer>> addCustomers(
Future<void> addCustomers(
int transactionId,
List<TransactionCustomerCreateInput> customers,
) async {
final response = await _api.post<Map<String, dynamic>>(
await _api.post<void>(
'$_basePath/$transactionId/customers',
data: {
'id': transactionId,
@@ -31,17 +28,15 @@ class TransactionCustomerRepositoryRemote
.map((customer) => customer.toJson())
.toList(growable: false),
},
options: Options(responseType: ResponseType.json),
);
return _parseCustomers(response.data);
}
@override
Future<List<StockTransactionCustomer>> updateCustomers(
Future<void> updateCustomers(
int transactionId,
List<TransactionCustomerUpdateInput> customers,
) async {
final response = await _api.patch<Map<String, dynamic>>(
await _api.patch<void>(
'$_basePath/$transactionId/customers',
data: {
'id': transactionId,
@@ -49,38 +44,11 @@ class TransactionCustomerRepositoryRemote
.map((customer) => customer.toJson())
.toList(growable: false),
},
options: Options(responseType: ResponseType.json),
);
return _parseCustomers(response.data);
}
@override
Future<void> deleteCustomer(int customerLinkId) async {
await _api.delete<void>('$_customerPath/$customerLinkId');
}
List<StockTransactionCustomer> _parseCustomers(Map<String, dynamic>? body) {
final data = _extractData(body);
if (data['customers'] is List) {
final dto = StockTransactionDto.fromJson(data);
return dto.customers;
}
if (data.containsKey('id')) {
final dto = StockTransactionDto.fromJson({
'customers': [data],
});
return dto.customers;
}
return const [];
}
Map<String, dynamic> _extractData(Map<String, dynamic>? body) {
if (body == null) {
return <String, dynamic>{};
}
if (body['data'] is Map<String, dynamic>) {
return body['data'] as Map<String, dynamic>;
}
return body;
}
}

View File

@@ -1,11 +1,8 @@
import 'package:dio/dio.dart';
import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/core/network/api_routes.dart';
import '../../domain/entities/stock_transaction.dart';
import '../../domain/entities/stock_transaction_input.dart';
import '../../domain/repositories/stock_transaction_repository.dart';
import '../dtos/stock_transaction_dto.dart';
/// 재고 트랜잭션 라인 API를 호출하는 원격 저장소 구현체.
class TransactionLineRepositoryRemote implements TransactionLineRepository {
@@ -18,35 +15,31 @@ class TransactionLineRepositoryRemote implements TransactionLineRepository {
static const _linePath = '${ApiRoutes.apiV1}/transaction-lines';
@override
Future<List<StockTransactionLine>> addLines(
Future<void> addLines(
int transactionId,
List<TransactionLineCreateInput> lines,
) async {
final response = await _api.post<Map<String, dynamic>>(
await _api.post<void>(
'$_basePath/$transactionId/lines',
data: {
'id': transactionId,
'lines': lines.map((line) => line.toJson()).toList(growable: false),
},
options: Options(responseType: ResponseType.json),
);
return _parseLines(response.data);
}
@override
Future<List<StockTransactionLine>> updateLines(
Future<void> updateLines(
int transactionId,
List<TransactionLineUpdateInput> lines,
) async {
final response = await _api.patch<Map<String, dynamic>>(
await _api.patch<void>(
'$_basePath/$transactionId/lines',
data: {
'id': transactionId,
'lines': lines.map((line) => line.toJson()).toList(growable: false),
},
options: Options(responseType: ResponseType.json),
);
return _parseLines(response.data);
}
@override
@@ -55,40 +48,10 @@ class TransactionLineRepositoryRemote implements TransactionLineRepository {
}
@override
Future<StockTransactionLine> restoreLine(int lineId) async {
final response = await _api.post<Map<String, dynamic>>(
Future<void> restoreLine(int lineId) async {
await _api.post<void>(
'$_linePath/$lineId/restore',
options: Options(responseType: ResponseType.json),
);
final lines = _parseLines(response.data);
if (lines.isEmpty) {
throw StateError('복구된 라인 정보를 찾을 수 없습니다.');
}
return lines.first;
}
List<StockTransactionLine> _parseLines(Map<String, dynamic>? body) {
final data = _extractData(body);
if (data['lines'] is List) {
final dto = StockTransactionDto.fromJson(data);
return dto.lines;
}
if (data.containsKey('id')) {
final dto = StockTransactionDto.fromJson({
'lines': [data],
});
return dto.lines;
}
return const [];
}
Map<String, dynamic> _extractData(Map<String, dynamic>? body) {
if (body == null) {
return <String, dynamic>{};
}
if (body['data'] is Map<String, dynamic>) {
return body['data'] as Map<String, dynamic>;
}
return body;
}
}

View File

@@ -11,6 +11,7 @@ class StockTransactionCreateInput {
this.expectedReturnDate,
this.lines = const [],
this.customers = const [],
this.approval,
});
final String? transactionNo;
@@ -23,6 +24,7 @@ class StockTransactionCreateInput {
final DateTime? expectedReturnDate;
final List<TransactionLineCreateInput> lines;
final List<TransactionCustomerCreateInput> customers;
final StockTransactionApprovalInput? approval;
Map<String, dynamic> toPayload() {
return {
@@ -42,6 +44,7 @@ class StockTransactionCreateInput {
'customers': customers
.map((customer) => customer.toJson())
.toList(growable: false),
if (approval != null) 'approval': approval!.toJson(),
};
}
}
@@ -200,3 +203,27 @@ class StockTransactionListFilter {
};
}
}
/// 재고 트랜잭션 생성 시 결재(Approval) 정보를 담는 입력 모델.
class StockTransactionApprovalInput {
StockTransactionApprovalInput({
required this.approvalNo,
required this.requestedById,
this.approvalStatusId,
this.note,
});
final String approvalNo;
final int requestedById;
final int? approvalStatusId;
final String? note;
Map<String, dynamic> toJson() {
return {
'approval_no': approvalNo,
if (approvalStatusId != null) 'approval_status_id': approvalStatusId,
'requested_by_id': requestedById,
if (note != null && note!.trim().isNotEmpty) 'note': note,
};
}
}

View File

@@ -47,13 +47,13 @@ abstract class StockTransactionRepository {
/// 재고 트랜잭션 라인 저장소 인터페이스.
abstract class TransactionLineRepository {
/// 라인을 추가한다.
Future<List<StockTransactionLine>> addLines(
Future<void> addLines(
int transactionId,
List<TransactionLineCreateInput> lines,
);
/// 라인 정보를 일괄 수정한다.
Future<List<StockTransactionLine>> updateLines(
Future<void> updateLines(
int transactionId,
List<TransactionLineUpdateInput> lines,
);
@@ -62,19 +62,19 @@ abstract class TransactionLineRepository {
Future<void> deleteLine(int lineId);
/// 삭제된 라인을 복구한다.
Future<StockTransactionLine> restoreLine(int lineId);
Future<void> restoreLine(int lineId);
}
/// 재고 트랜잭션 고객 연결 저장소 인터페이스.
abstract class TransactionCustomerRepository {
/// 고객 연결을 추가한다.
Future<List<StockTransactionCustomer>> addCustomers(
Future<void> addCustomers(
int transactionId,
List<TransactionCustomerCreateInput> customers,
);
/// 고객 연결 정보를 수정한다.
Future<List<StockTransactionCustomer>> updateCustomers(
Future<void> updateCustomers(
int transactionId,
List<TransactionCustomerUpdateInput> customers,
);

View File

@@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../core/constants/app_sections.dart';
import '../../../../core/network/api_error.dart';
import '../../../../core/network/failure.dart';
import '../../../../core/permissions/permission_manager.dart';
import '../../../../core/permissions/permission_resources.dart';
@@ -73,9 +74,10 @@ class _LoginPageState extends State<LoginPage> {
if (!mounted) return;
final failure = Failure.from(error);
final description = failure.describe();
final message = description.isEmpty
? '권한 정보를 불러오지 못했습니다. 잠시 후 다시 시도하세요.'
: description;
final hasApiDetails = failure.raw is ApiException;
final message = hasApiDetails && description.isNotEmpty
? description
: '권한 정보를 불러오지 못했습니다. 잠시 후 다시 시도하세요.';
setState(() {
errorMessage = message;
isLoading = false;

View File

@@ -133,7 +133,7 @@ class GroupPermissionMenuDto {
id: json['id'] as int? ?? json['menu_id'] as int,
menuCode: code,
menuName: fallbackName,
path: json['path'] as String?,
path: (json['path'] ?? json['route_path']) as String?,
);
}

View File

@@ -49,10 +49,12 @@ class ReportingRepositoryRemote implements ReportingRepository {
'from': request.from.toIso8601String(),
'to': request.to.toIso8601String(),
'format': request.format.apiValue,
if (request.transactionTypeId != null)
'type_id': request.transactionTypeId,
if (request.statusId != null) 'status_id': request.statusId,
if (request.warehouseId != null) 'warehouse_id': request.warehouseId,
if (request.transactionStatusId != null)
'transaction_status_id': request.transactionStatusId,
if (request.approvalStatusId != null)
'approval_status_id': request.approvalStatusId,
if (request.requestedById != null)
'requested_by_id': request.requestedById,
};
}

View File

@@ -6,9 +6,9 @@ class ReportExportRequest {
required this.from,
required this.to,
required this.format,
this.transactionTypeId,
this.statusId,
this.warehouseId,
this.transactionStatusId,
this.approvalStatusId,
this.requestedById,
});
/// 조회 시작 일자.
@@ -20,12 +20,12 @@ class ReportExportRequest {
/// 내보내기 파일 형식.
final ReportExportFormat format;
/// 재고 트랜잭션 유형 식별자.
final int? transactionTypeId;
/// 트랜잭션 상태 식별자.
final int? transactionStatusId;
/// 결재 상태 식별자.
final int? statusId;
final int? approvalStatusId;
/// 창고 식별자.
final int? warehouseId;
/// 상신자(요청자) 식별자.
final int? requestedById;
}

View File

@@ -7,8 +7,11 @@ import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:superport_v2/core/constants/app_sections.dart';
import 'package:superport_v2/core/network/failure.dart';
import 'package:superport_v2/core/services/file_saver.dart';
import 'package:superport_v2/features/inventory/lookups/domain/entities/lookup_item.dart';
import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
import 'package:superport_v2/features/inventory/shared/widgets/employee_autocomplete_field.dart';
import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart';
import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart';
import 'package:superport_v2/features/reporting/domain/entities/report_download_result.dart';
@@ -20,7 +23,6 @@ import 'package:superport_v2/widgets/components/empty_state.dart';
import 'package:superport_v2/widgets/components/feedback.dart';
import 'package:superport_v2/widgets/components/filter_bar.dart';
import 'package:superport_v2/widgets/components/superport_date_picker.dart';
import 'package:superport_v2/core/network/failure.dart';
/// 보고서 다운로드 화면 루트 위젯.
class ReportingPage extends StatefulWidget {
@@ -36,7 +38,6 @@ class _ReportingPageState extends State<ReportingPage> {
ReportingRepository? _reportingRepository;
InventoryLookupRepository? _lookupRepository;
final intl.DateFormat _dateFormat = intl.DateFormat('yyyy.MM.dd');
final Map<ReportTypeFilter, LookupItem> _transactionTypeLookup = {};
final Map<ReportStatusFilter, LookupItem> _transactionStatusLookup = {};
final Map<ReportStatusFilter, LookupItem> _approvalStatusLookup = {};
bool _isLoadingLookups = false;
@@ -45,12 +46,9 @@ class _ReportingPageState extends State<ReportingPage> {
String? _exportError;
ReportDownloadResult? _lastResult;
ReportExportFormat? _lastFormat;
static const Map<ReportTypeFilter, List<String>> _transactionTypeKeywords = {
ReportTypeFilter.inbound: ['입고', 'inbound'],
ReportTypeFilter.outbound: ['출고', 'outbound'],
ReportTypeFilter.rental: ['대여', 'rent', 'rental'],
};
final TextEditingController _requesterController = TextEditingController();
InventoryEmployeeSuggestion? _appliedRequester;
InventoryEmployeeSuggestion? _pendingRequester;
static const Map<ReportStatusFilter, List<String>>
_transactionStatusKeywords = {
@@ -105,6 +103,12 @@ class _ReportingPageState extends State<ReportingPage> {
_loadWarehouses();
}
@override
void dispose() {
_requesterController.dispose();
super.dispose();
}
/// 활성 창고 목록을 불러와 드롭다운 옵션을 준비한다.
Future<void> _loadWarehouses() async {
setState(() {
@@ -173,16 +177,12 @@ class _ReportingPageState extends State<ReportingPage> {
_lookupError = null;
});
try {
final transactionTypes = await repository.fetchTransactionTypes();
final transactionStatuses = await repository.fetchTransactionStatuses();
final approvalStatuses = await repository.fetchApprovalStatuses();
if (!mounted) {
return;
}
setState(() {
_transactionTypeLookup
..clear()
..addAll(_mapTransactionTypes(transactionTypes));
_transactionStatusLookup
..clear()
..addAll(
@@ -228,6 +228,9 @@ class _ReportingPageState extends State<ReportingPage> {
_pendingStatus = ReportStatusFilter.all;
_appliedWarehouse = WarehouseFilterOption.all;
_pendingWarehouse = WarehouseFilterOption.all;
_appliedRequester = null;
_pendingRequester = null;
_requesterController.clear();
});
}
@@ -238,6 +241,7 @@ class _ReportingPageState extends State<ReportingPage> {
_appliedType = _pendingType;
_appliedStatus = _pendingStatus;
_appliedWarehouse = _pendingWarehouse;
_appliedRequester = _pendingRequester;
});
}
@@ -249,7 +253,8 @@ class _ReportingPageState extends State<ReportingPage> {
return _appliedDateRange != null ||
_appliedType != ReportTypeFilter.all ||
_appliedStatus != ReportStatusFilter.all ||
_appliedWarehouse != WarehouseFilterOption.all;
_appliedWarehouse != WarehouseFilterOption.all ||
_appliedRequester != null;
}
bool get _hasAppliedFilters => _hasCustomFilters;
@@ -258,7 +263,8 @@ class _ReportingPageState extends State<ReportingPage> {
!_isSameRange(_pendingDateRange, _appliedDateRange) ||
_pendingType != _appliedType ||
_pendingStatus != _appliedStatus ||
_pendingWarehouse != _appliedWarehouse;
_pendingWarehouse != _appliedWarehouse ||
!_isSameRequester(_pendingRequester, _appliedRequester);
bool _isSameRange(DateTimeRange? a, DateTimeRange? b) {
if (identical(a, b)) {
@@ -270,6 +276,19 @@ class _ReportingPageState extends State<ReportingPage> {
return a.start == b.start && a.end == b.end;
}
bool _isSameRequester(
InventoryEmployeeSuggestion? a,
InventoryEmployeeSuggestion? b,
) {
if (identical(a, b)) {
return true;
}
if (a == null || b == null) {
return a == b;
}
return a.id == b.id;
}
WarehouseFilterOption _resolveWarehouseOption(
WarehouseFilterOption target,
List<WarehouseFilterOption> options,
@@ -282,19 +301,6 @@ class _ReportingPageState extends State<ReportingPage> {
return options.first;
}
Map<ReportTypeFilter, LookupItem> _mapTransactionTypes(
List<LookupItem> items,
) {
final result = <ReportTypeFilter, LookupItem>{};
for (final entry in _transactionTypeKeywords.entries) {
final matched = _matchLookup(items, entry.value);
if (matched != null) {
result[entry.key] = matched;
}
}
return result;
}
Map<ReportStatusFilter, LookupItem> _mapStatusByKeyword(
List<LookupItem> items,
Map<ReportStatusFilter, List<String>> keywords,
@@ -326,25 +332,20 @@ class _ReportingPageState extends State<ReportingPage> {
return null;
}
int? _resolveTransactionTypeId() {
if (_appliedType == ReportTypeFilter.all ||
_appliedType == ReportTypeFilter.approval) {
return null;
}
final lookup = _transactionTypeLookup[_appliedType];
return lookup?.id;
}
int? _resolveStatusId() {
int? _resolveTransactionStatusId() {
if (_appliedStatus == ReportStatusFilter.all) {
return null;
}
if (_appliedType == ReportTypeFilter.approval) {
return _approvalStatusLookup[_appliedStatus]?.id;
}
return _transactionStatusLookup[_appliedStatus]?.id;
}
int? _resolveApprovalStatusId() {
if (_appliedStatus == ReportStatusFilter.all) {
return null;
}
return _approvalStatusLookup[_appliedStatus]?.id;
}
String _dateRangeLabel(DateTimeRange? range) {
if (range == null) {
return '기간 선택';
@@ -354,6 +355,13 @@ class _ReportingPageState extends State<ReportingPage> {
String _formatDate(DateTime value) => _dateFormat.format(value);
String _requesterLabel(InventoryEmployeeSuggestion? suggestion) {
if (suggestion == null) {
return '전체 상신자';
}
return '${suggestion.name} (${suggestion.employeeNo})';
}
Future<void> _handleExport(ReportExportFormat format) async {
if (_isExporting) {
return;
@@ -376,9 +384,9 @@ class _ReportingPageState extends State<ReportingPage> {
from: range.start,
to: range.end,
format: format,
transactionTypeId: _resolveTransactionTypeId(),
statusId: _resolveStatusId(),
warehouseId: _appliedWarehouse.id,
transactionStatusId: _resolveTransactionStatusId(),
approvalStatusId: _resolveApprovalStatusId(),
requestedById: _appliedRequester?.id,
);
try {
final result = _appliedType == ReportTypeFilter.approval
@@ -394,7 +402,7 @@ class _ReportingPageState extends State<ReportingPage> {
if (result.hasDownloadUrl) {
SuperportToast.success(context, '다운로드 링크가 준비되었습니다.');
} else if (result.hasBytes) {
SuperportToast.success(context, '보고서 파일이 준비되었습니다. 저장 기능은 추후 제공 예정입니다.');
await _saveBinaryResult(result, format);
} else {
SuperportToast.info(context, '다운로드 결과를 확인했지만 추가 처리 항목이 없습니다.');
}
@@ -419,6 +427,51 @@ class _ReportingPageState extends State<ReportingPage> {
}
}
Future<void> _saveBinaryResult(
ReportDownloadResult result,
ReportExportFormat format,
) async {
final bytes = result.bytes;
if (bytes == null || bytes.isEmpty) {
return;
}
final filename = result.filename ?? 'report.${format.apiValue}';
final mimeType = result.mimeType ?? _mimeTypeForFormat(format);
try {
await saveFileBytes(bytes: bytes, filename: filename, mimeType: mimeType);
if (!mounted) {
return;
}
SuperportToast.success(context, '보고서 파일 다운로드가 시작되었습니다.');
} on UnsupportedError {
if (!mounted) {
return;
}
SuperportToast.info(
context,
'현재 환경에서는 자동 저장을 지원하지 않습니다. 다운로드 링크 요청 기능을 이용하세요.',
);
} catch (_) {
if (!mounted) {
return;
}
SuperportToast.error(context, '파일을 저장하는 중 문제가 발생했습니다. 잠시 후 다시 시도하세요.');
}
}
void _notifyPdfUnavailable() {
SuperportToast.info(context, 'PDF 다운로드는 현재 지원되지 않습니다.');
}
String _mimeTypeForFormat(ReportExportFormat format) {
switch (format) {
case ReportExportFormat.xlsx:
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
case ReportExportFormat.pdf:
return 'application/pdf';
}
}
Future<void> _launchDownloadUrl(Uri url) async {
try {
final opened = await launchUrl(url, mode: LaunchMode.externalApplication);
@@ -476,10 +529,7 @@ class _ReportingPageState extends State<ReportingPage> {
if (result.downloadUrl != null)
_SummaryRow(label: '다운로드 URL', value: result.downloadUrl!.toString()),
if (result.hasBytes && (result.downloadUrl == null))
const _SummaryRow(
label: '상태',
value: '바이너리 응답이 준비되었습니다. 저장 기능은 추후 제공 예정입니다.',
),
const _SummaryRow(label: '상태', value: '바이너리 응답을 받아 자동 다운로드를 실행했습니다.'),
];
return Column(
@@ -530,9 +580,7 @@ class _ReportingPageState extends State<ReportingPage> {
child: const Text('XLSX 다운로드'),
),
ShadButton.outline(
onPressed: _canExport
? () => _handleExport(ReportExportFormat.pdf)
: null,
onPressed: _canExport ? _notifyPdfUnavailable : null,
leading: const Icon(lucide.LucideIcons.fileText, size: 16),
child: const Text('PDF 다운로드'),
),
@@ -648,6 +696,40 @@ class _ReportingPageState extends State<ReportingPage> {
],
),
),
SizedBox(
width: 260,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'상신자',
style: theme.textTheme.small.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
InventoryEmployeeAutocompleteField(
key: ValueKey(_pendingRequester?.id ?? 'none'),
controller: _requesterController,
initialSuggestion: _pendingRequester,
placeholder: '상신자 이름 또는 사번 검색',
onSuggestionSelected: (suggestion) {
setState(() {
_pendingRequester = suggestion;
});
},
onChanged: () {
if (_requesterController.text.trim().isEmpty &&
_pendingRequester != null) {
setState(() {
_pendingRequester = null;
});
}
},
),
],
),
),
],
),
child: Column(
@@ -712,6 +794,10 @@ class _ReportingPageState extends State<ReportingPage> {
_SummaryRow(label: '유형', value: _appliedType.label),
_SummaryRow(label: '창고', value: _appliedWarehouse.label),
_SummaryRow(label: '상태', value: _appliedStatus.label),
_SummaryRow(
label: '상신자',
value: _requesterLabel(_appliedRequester),
),
if (!_canExport)
Padding(
padding: const EdgeInsets.only(top: 12),