feat(dialog): 상세 팝업 SuperportDetailDialog 통합
- SuperportDetailDialog 위젯과 showSuperportDetailDialog 헬퍼를 추가하고 metadata/섹션 패턴을 표준화 - 결재/재고/마스터 각 상세 다이얼로그를 dialogs 디렉터리에 신설하고 기존 페이지를 신규 팝업으로 전환 - SuperportTable 행 선택과 우편번호 검색 다이얼로그 onRowTap 보정을 통해 헤더 오프셋 버그를 제거 - 상세 다이얼로그 및 트랜잭션/상세 뷰 전용 위젯 테스트와 tester_extensions 유틸을 추가하여 회귀를 방지 - detail_dialog_unification_plan.md로 작업 배경과 필드 통합 계획을 문서화
This commit is contained in:
@@ -9,15 +9,13 @@ import '../../../../../core/constants/app_sections.dart';
|
||||
import '../../../../../widgets/app_layout.dart';
|
||||
import '../../../../../widgets/components/filter_bar.dart';
|
||||
import '../../../../../widgets/components/superport_table.dart';
|
||||
import '../../../../../widgets/components/superport_dialog.dart';
|
||||
import '../../../../../widgets/components/feature_disabled_placeholder.dart';
|
||||
import '../../../shared/widgets/approver_autocomplete_field.dart';
|
||||
import '../../../domain/entities/approval_template.dart';
|
||||
import '../../../domain/repositories/approval_template_repository.dart';
|
||||
import '../../../domain/usecases/apply_approval_template_use_case.dart';
|
||||
import '../../../domain/usecases/save_approval_template_use_case.dart';
|
||||
import '../../../../auth/application/auth_service.dart';
|
||||
import '../controllers/approval_template_controller.dart';
|
||||
import '../dialogs/approval_template_detail_dialog.dart';
|
||||
|
||||
/// 결재 템플릿 관리 페이지. 기능 플래그에 따라 준비중 화면을 노출한다.
|
||||
class ApprovalTemplatePage extends StatelessWidget {
|
||||
@@ -138,9 +136,7 @@ class _ApprovalTemplateEnabledPageState
|
||||
actions: [
|
||||
ShadButton(
|
||||
leading: const Icon(lucide.LucideIcons.plus, size: 16),
|
||||
onPressed: _controller.isSubmitting
|
||||
? null
|
||||
: () => _openTemplateForm(),
|
||||
onPressed: _controller.isSubmitting ? null : _openCreateTemplate,
|
||||
child: const Text('템플릿 생성'),
|
||||
),
|
||||
],
|
||||
@@ -218,7 +214,6 @@ class _ApprovalTemplateEnabledPageState
|
||||
ShadTableCell.header(child: Text('설명')),
|
||||
ShadTableCell.header(child: Text('사용')),
|
||||
ShadTableCell.header(child: Text('변경일시')),
|
||||
ShadTableCell.header(child: Text('동작')),
|
||||
],
|
||||
rows: templates.map((template) {
|
||||
return [
|
||||
@@ -253,49 +248,6 @@ class _ApprovalTemplateEnabledPageState
|
||||
),
|
||||
),
|
||||
),
|
||||
ShadTableCell(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
ShadButton.ghost(
|
||||
key: ValueKey(
|
||||
'template_preview_${template.id}',
|
||||
),
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: () =>
|
||||
_openTemplatePreview(template.id),
|
||||
child: const Text('보기'),
|
||||
),
|
||||
ShadButton.ghost(
|
||||
key: ValueKey(
|
||||
'template_edit_${template.id}',
|
||||
),
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isSubmitting
|
||||
? null
|
||||
: () => _openEditTemplate(template),
|
||||
child: const Text('수정'),
|
||||
),
|
||||
template.isActive
|
||||
? ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isSubmitting
|
||||
? null
|
||||
: () => _confirmDelete(template),
|
||||
child: const Text('삭제'),
|
||||
)
|
||||
: ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isSubmitting
|
||||
? null
|
||||
: () => _confirmRestore(template),
|
||||
child: const Text('복구'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}).toList(),
|
||||
rowHeight: 58,
|
||||
@@ -316,8 +268,6 @@ class _ApprovalTemplateEnabledPageState
|
||||
return const FixedTableSpanExtent(100);
|
||||
case 6:
|
||||
return const FixedTableSpanExtent(180);
|
||||
case 7:
|
||||
return const FixedTableSpanExtent(220);
|
||||
default:
|
||||
return const FixedTableSpanExtent(140);
|
||||
}
|
||||
@@ -335,6 +285,22 @@ class _ApprovalTemplateEnabledPageState
|
||||
_controller.fetch(page: 1);
|
||||
},
|
||||
isLoading: _controller.isLoading,
|
||||
onRowTap: _controller.isSubmitting
|
||||
? null
|
||||
: (index) {
|
||||
if (templates.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final int safeIndex;
|
||||
if (index < 0) {
|
||||
safeIndex = 0;
|
||||
} else if (index >= templates.length) {
|
||||
safeIndex = templates.length - 1;
|
||||
} else {
|
||||
safeIndex = index;
|
||||
}
|
||||
_openTemplateDetail(templates[safeIndex]);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -356,144 +322,18 @@ class _ApprovalTemplateEnabledPageState
|
||||
_searchFocus.requestFocus();
|
||||
}
|
||||
|
||||
String _generateTemplateCode() {
|
||||
final authService = GetIt.I<AuthService>();
|
||||
final session = authService.session;
|
||||
String normalizedEmployee = '';
|
||||
|
||||
final candidateValues = <String?>[
|
||||
session?.user.employeeNo,
|
||||
session?.user.email,
|
||||
session?.user.name,
|
||||
];
|
||||
for (final candidate in candidateValues) {
|
||||
if (candidate == null) {
|
||||
continue;
|
||||
}
|
||||
var source = candidate.trim();
|
||||
final atIndex = source.indexOf('@');
|
||||
if (atIndex > 0) {
|
||||
source = source.substring(0, atIndex);
|
||||
}
|
||||
final normalized = source.toUpperCase().replaceAll(
|
||||
RegExp(r'[^A-Z0-9]'),
|
||||
'',
|
||||
);
|
||||
if (normalized.isNotEmpty) {
|
||||
normalizedEmployee = normalized;
|
||||
break;
|
||||
}
|
||||
String _statusLabel(ApprovalTemplateStatusFilter filter) {
|
||||
switch (filter) {
|
||||
case ApprovalTemplateStatusFilter.all:
|
||||
return '전체(사용/미사용)';
|
||||
case ApprovalTemplateStatusFilter.activeOnly:
|
||||
return '사용중';
|
||||
case ApprovalTemplateStatusFilter.inactiveOnly:
|
||||
return '미사용';
|
||||
}
|
||||
if (normalizedEmployee.isEmpty && session?.user.id != null) {
|
||||
normalizedEmployee = session!.user.id.toString();
|
||||
}
|
||||
|
||||
final suffixSource = normalizedEmployee.isEmpty
|
||||
? '0000'
|
||||
: normalizedEmployee;
|
||||
final suffix = suffixSource.length >= 4
|
||||
? suffixSource.substring(suffixSource.length - 4)
|
||||
: suffixSource.padLeft(4, '0');
|
||||
final timestamp = intl.DateFormat(
|
||||
'yyMMddHHmmssSSS',
|
||||
).format(DateTime.now().toUtc());
|
||||
return 'AP_TEMP_${suffix}_$timestamp';
|
||||
}
|
||||
|
||||
Future<void> _openTemplatePreview(int templateId) async {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
final detail = await _controller.fetchDetail(templateId);
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (detail == null) {
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
messenger?.showSnackBar(
|
||||
const SnackBar(content: Text('템플릿 정보를 불러오지 못했습니다. 다시 시도하세요.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final theme = ShadTheme.of(context);
|
||||
await SuperportDialog.show<void>(
|
||||
context: context,
|
||||
dialog: SuperportDialog(
|
||||
title: detail.name,
|
||||
description: detail.description,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 540),
|
||||
child: detail.steps.isEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text('등록된 결재 단계가 없습니다.', style: theme.textTheme.muted),
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final step in detail.steps) ...[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: theme.colorScheme.secondary.withValues(
|
||||
alpha: 0.12,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'${step.stepOrder}',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
step.approver.name,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'사번 ${step.approver.employeeNo}',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
if (step.note?.isNotEmpty ?? false)
|
||||
Text(
|
||||
step.note!,
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openEditTemplate(ApprovalTemplate template) async {
|
||||
Future<void> _openTemplateDetail(ApprovalTemplate template) async {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
@@ -503,366 +343,44 @@ class _ApprovalTemplateEnabledPageState
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
if (!mounted || detail == null) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final success = await _openTemplateForm(template: detail);
|
||||
if (!mounted || success != true) return;
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
messenger?.showSnackBar(
|
||||
SnackBar(content: Text('템플릿 "${detail.name}"을(를) 수정했습니다.')),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(ApprovalTemplate template) async {
|
||||
final confirmed = await SuperportDialog.show<bool>(
|
||||
if (detail == null) {
|
||||
_showSnack('템플릿 정보를 불러오지 못했습니다. 다시 시도하세요.');
|
||||
return;
|
||||
}
|
||||
final result = await showApprovalTemplateDetailDialog(
|
||||
context: context,
|
||||
dialog: SuperportDialog(
|
||||
title: '템플릿 삭제',
|
||||
description:
|
||||
'"${template.name}" 템플릿을 삭제하시겠습니까?\n삭제 시 템플릿은 미사용 상태로 전환됩니다.',
|
||||
actions: [
|
||||
ShadButton.ghost(
|
||||
onPressed: () =>
|
||||
Navigator.of(context, rootNavigator: true).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
ShadButton(
|
||||
onPressed: () =>
|
||||
Navigator.of(context, rootNavigator: true).pop(true),
|
||||
child: const Text('삭제'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
final ok = await _controller.delete(template.id);
|
||||
if (!mounted || !ok) return;
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
messenger?.showSnackBar(
|
||||
SnackBar(content: Text('템플릿 "${template.name}"을(를) 삭제했습니다.')),
|
||||
dateFormat: _dateFormat,
|
||||
template: detail,
|
||||
onCreate: _controller.create,
|
||||
onUpdate: _controller.update,
|
||||
onDelete: _controller.delete,
|
||||
onRestore: _controller.restore,
|
||||
);
|
||||
if (result != null && mounted) {
|
||||
_showSnack(result.message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmRestore(ApprovalTemplate template) async {
|
||||
final confirmed = await SuperportDialog.show<bool>(
|
||||
Future<void> _openCreateTemplate() async {
|
||||
final result = await showApprovalTemplateDetailDialog(
|
||||
context: context,
|
||||
dialog: SuperportDialog(
|
||||
title: '템플릿 복구',
|
||||
description: '"${template.name}" 템플릿을 복구하시겠습니까?',
|
||||
actions: [
|
||||
ShadButton.ghost(
|
||||
onPressed: () =>
|
||||
Navigator.of(context, rootNavigator: true).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
ShadButton(
|
||||
onPressed: () =>
|
||||
Navigator.of(context, rootNavigator: true).pop(true),
|
||||
child: const Text('복구'),
|
||||
),
|
||||
],
|
||||
),
|
||||
dateFormat: _dateFormat,
|
||||
onCreate: _controller.create,
|
||||
onUpdate: _controller.update,
|
||||
onDelete: _controller.delete,
|
||||
onRestore: _controller.restore,
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
final restored = await _controller.restore(template.id);
|
||||
if (!mounted || restored == null) return;
|
||||
if (result != null && mounted) {
|
||||
_showSnack(result.message);
|
||||
}
|
||||
}
|
||||
|
||||
void _showSnack(String message) {
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
messenger?.showSnackBar(
|
||||
SnackBar(content: Text('템플릿 "${restored.name}"을(를) 복구했습니다.')),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool?> _openTemplateForm({ApprovalTemplate? template}) async {
|
||||
final isEdit = template != null;
|
||||
final existingTemplate = template;
|
||||
final codeController = TextEditingController(
|
||||
text: isEdit ? existingTemplate!.code : _generateTemplateCode(),
|
||||
);
|
||||
final nameController = TextEditingController(text: template?.name ?? '');
|
||||
final descriptionController = TextEditingController(
|
||||
text: template?.description ?? '',
|
||||
);
|
||||
final noteController = TextEditingController(text: template?.note ?? '');
|
||||
final steps = _buildStepFields(template);
|
||||
final statusNotifier = ValueNotifier<bool>(template?.isActive ?? true);
|
||||
bool isSaving = false;
|
||||
String? errorText;
|
||||
StateSetter? modalSetState;
|
||||
|
||||
Future<void> handleSubmit() async {
|
||||
if (isSaving) return;
|
||||
final codeValue = codeController.text.trim();
|
||||
final nameValue = nameController.text.trim();
|
||||
if (!isEdit && codeValue.isEmpty) {
|
||||
modalSetState?.call(() => errorText = '템플릿 코드를 입력하세요.');
|
||||
return;
|
||||
}
|
||||
if (nameValue.isEmpty) {
|
||||
modalSetState?.call(() => errorText = '템플릿명을 입력하세요.');
|
||||
return;
|
||||
}
|
||||
final validation = _validateSteps(steps);
|
||||
if (validation != null) {
|
||||
modalSetState?.call(() => errorText = validation);
|
||||
return;
|
||||
}
|
||||
modalSetState?.call(() => errorText = null);
|
||||
final stepInputs = steps
|
||||
.map(
|
||||
(field) => ApprovalTemplateStepInput(
|
||||
id: field.id,
|
||||
stepOrder: int.parse(field.orderController.text.trim()),
|
||||
approverId: int.parse(field.approverController.text.trim()),
|
||||
note: field.noteController.text.trim().isEmpty
|
||||
? null
|
||||
: field.noteController.text.trim(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
final input = ApprovalTemplateInput(
|
||||
code: isEdit ? existingTemplate!.code : codeValue,
|
||||
name: nameValue,
|
||||
description: descriptionController.text.trim().isEmpty
|
||||
? null
|
||||
: descriptionController.text.trim(),
|
||||
note: noteController.text.trim().isEmpty
|
||||
? null
|
||||
: noteController.text.trim(),
|
||||
isActive: statusNotifier.value,
|
||||
);
|
||||
|
||||
modalSetState?.call(() => isSaving = true);
|
||||
|
||||
final success = isEdit
|
||||
? await _controller.update(existingTemplate!.id, input, stepInputs)
|
||||
: await _controller.create(input, stepInputs);
|
||||
if (success != null && mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop(true);
|
||||
} else {
|
||||
modalSetState?.call(() => isSaving = false);
|
||||
}
|
||||
}
|
||||
|
||||
final result = await showSuperportDialog<bool>(
|
||||
context: context,
|
||||
title: isEdit ? '템플릿 수정' : '템플릿 생성',
|
||||
barrierDismissible: !isSaving,
|
||||
onSubmit: handleSubmit,
|
||||
body: StatefulBuilder(
|
||||
builder: (dialogContext, setModalState) {
|
||||
modalSetState = setModalState;
|
||||
final theme = ShadTheme.of(dialogContext);
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 640),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!isEdit)
|
||||
_FormField(
|
||||
label: '템플릿 코드',
|
||||
child: ShadInput(
|
||||
controller: codeController,
|
||||
readOnly: true,
|
||||
enabled: false,
|
||||
placeholder: const Text('예: AP_INBOUND'),
|
||||
),
|
||||
),
|
||||
_FormField(
|
||||
label: '템플릿명',
|
||||
child: ShadInput(controller: nameController),
|
||||
),
|
||||
_FormField(
|
||||
label: '설명',
|
||||
child: ShadTextarea(
|
||||
controller: descriptionController,
|
||||
minHeight: 80,
|
||||
maxHeight: 200,
|
||||
),
|
||||
),
|
||||
_FormField(
|
||||
label: '사용 여부',
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: statusNotifier,
|
||||
builder: (_, value, __) {
|
||||
return Row(
|
||||
children: [
|
||||
ShadSwitch(
|
||||
value: value,
|
||||
onChanged: isSaving
|
||||
? null
|
||||
: (next) => statusNotifier.value = next,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(value ? '사용' : '미사용'),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
_FormField(
|
||||
label: '비고',
|
||||
child: ShadTextarea(
|
||||
controller: noteController,
|
||||
minHeight: 80,
|
||||
maxHeight: 200,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'결재 단계',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Column(
|
||||
children: [
|
||||
for (var index = 0; index < steps.length; index++)
|
||||
_StepEditorRow(
|
||||
key: ValueKey('step_field_$index'),
|
||||
field: steps[index],
|
||||
index: index,
|
||||
isEdit: isEdit,
|
||||
isDisabled: isSaving,
|
||||
onRemove: steps.length <= 1 || isSaving
|
||||
? null
|
||||
: () {
|
||||
setModalState(() {
|
||||
final removed = steps.removeAt(index);
|
||||
removed.dispose();
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: ShadButton.outline(
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () {
|
||||
setModalState(() {
|
||||
steps.add(
|
||||
_TemplateStepField.create(
|
||||
order: steps.length + 1,
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
Icon(lucide.LucideIcons.plus, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('단계 추가'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (errorText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
errorText!,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.destructive,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
ShadButton.ghost(
|
||||
onPressed: () {
|
||||
if (isSaving) return;
|
||||
Navigator.of(context, rootNavigator: true).pop(false);
|
||||
},
|
||||
child: const Text('취소'),
|
||||
),
|
||||
ShadButton(
|
||||
onPressed: handleSubmit,
|
||||
child: Text(isEdit ? '수정 완료' : '생성 완료'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final createdName = nameController.text.trim();
|
||||
|
||||
for (final field in steps) {
|
||||
field.dispose();
|
||||
}
|
||||
codeController.dispose();
|
||||
nameController.dispose();
|
||||
descriptionController.dispose();
|
||||
noteController.dispose();
|
||||
statusNotifier.dispose();
|
||||
|
||||
if (result == true && mounted && template == null) {
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
messenger?.showSnackBar(
|
||||
SnackBar(content: Text('템플릿 "$createdName"을 생성했습니다.')),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
String? _validateSteps(List<_TemplateStepField> fields) {
|
||||
if (fields.isEmpty) {
|
||||
return '최소 1개의 결재 단계를 등록하세요.';
|
||||
}
|
||||
for (var index = 0; index < fields.length; index++) {
|
||||
final field = fields[index];
|
||||
final orderText = field.orderController.text.trim();
|
||||
final approverText = field.approverController.text.trim();
|
||||
final order = int.tryParse(orderText);
|
||||
final approver = int.tryParse(approverText);
|
||||
if (order == null || order <= 0) {
|
||||
return '${index + 1}번째 단계의 순서를 올바르게 입력하세요.';
|
||||
}
|
||||
if (approver == null || approver <= 0) {
|
||||
return '${index + 1}번째 단계의 승인자ID를 올바르게 입력하세요.';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<_TemplateStepField> _buildStepFields(ApprovalTemplate? template) {
|
||||
if (template == null || template.steps.isEmpty) {
|
||||
return [_TemplateStepField.create(order: 1)];
|
||||
}
|
||||
return template.steps
|
||||
.map(
|
||||
(step) => _TemplateStepField(
|
||||
id: step.id,
|
||||
orderController: TextEditingController(
|
||||
text: step.stepOrder.toString(),
|
||||
),
|
||||
approverController: TextEditingController(
|
||||
text: step.approver.id.toString(),
|
||||
),
|
||||
noteController: TextEditingController(text: step.note ?? ''),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
String _statusLabel(ApprovalTemplateStatusFilter filter) {
|
||||
switch (filter) {
|
||||
case ApprovalTemplateStatusFilter.all:
|
||||
return '전체';
|
||||
case ApprovalTemplateStatusFilter.activeOnly:
|
||||
return '사용만';
|
||||
case ApprovalTemplateStatusFilter.inactiveOnly:
|
||||
return '미사용만';
|
||||
}
|
||||
messenger?.showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -955,139 +473,3 @@ class _TemplateStepSummaryCellState extends State<_TemplateStepSummaryCell> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FormField extends StatelessWidget {
|
||||
const _FormField({required this.label, required this.child});
|
||||
|
||||
final String label;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.small.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StepEditorRow extends StatelessWidget {
|
||||
const _StepEditorRow({
|
||||
super.key,
|
||||
required this.field,
|
||||
required this.index,
|
||||
required this.isEdit,
|
||||
required this.isDisabled,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
final _TemplateStepField field;
|
||||
final int index;
|
||||
final bool isEdit;
|
||||
final bool isDisabled;
|
||||
final VoidCallback? onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.border.withValues(alpha: 0.6),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ShadInput(
|
||||
controller: field.orderController,
|
||||
keyboardType: TextInputType.number,
|
||||
placeholder: const Text('단계 순서'),
|
||||
enabled: !isDisabled,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: IgnorePointer(
|
||||
ignoring: isDisabled,
|
||||
child: ApprovalApproverAutocompleteField(
|
||||
idController: field.approverController,
|
||||
hintText: '승인자 검색',
|
||||
onSelected: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
if (onRemove != null)
|
||||
ShadButton.ghost(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: onRemove,
|
||||
child: const Icon(lucide.LucideIcons.trash2, size: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
IgnorePointer(
|
||||
ignoring: isDisabled,
|
||||
child: ShadTextarea(
|
||||
controller: field.noteController,
|
||||
minHeight: 60,
|
||||
maxHeight: 160,
|
||||
placeholder: const Text('비고 (선택)'),
|
||||
),
|
||||
),
|
||||
if (isEdit && field.id != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text('단계 ID: ${field.id}', style: theme.textTheme.small),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TemplateStepField {
|
||||
_TemplateStepField({
|
||||
this.id,
|
||||
required this.orderController,
|
||||
required this.approverController,
|
||||
required this.noteController,
|
||||
});
|
||||
|
||||
final int? id;
|
||||
final TextEditingController orderController;
|
||||
final TextEditingController approverController;
|
||||
final TextEditingController noteController;
|
||||
|
||||
void dispose() {
|
||||
orderController.dispose();
|
||||
approverController.dispose();
|
||||
noteController.dispose();
|
||||
}
|
||||
|
||||
factory _TemplateStepField.create({required int order}) {
|
||||
return _TemplateStepField(
|
||||
orderController: TextEditingController(text: order.toString()),
|
||||
approverController: TextEditingController(),
|
||||
noteController: TextEditingController(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user