feat(dialog): 상세 팝업 SuperportDetailDialog 통합
- SuperportDetailDialog 위젯과 showSuperportDetailDialog 헬퍼를 추가하고 metadata/섹션 패턴을 표준화 - 결재/재고/마스터 각 상세 다이얼로그를 dialogs 디렉터리에 신설하고 기존 페이지를 신규 팝업으로 전환 - SuperportTable 행 선택과 우편번호 검색 다이얼로그 onRowTap 보정을 통해 헤더 오프셋 버그를 제거 - 상세 다이얼로그 및 트랜잭션/상세 뷰 전용 위젯 테스트와 tester_extensions 유틸을 추가하여 회귀를 방지 - detail_dialog_unification_plan.md로 작업 배경과 필드 통합 계획을 문서화
This commit is contained in:
@@ -0,0 +1,824 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../../../../widgets/components/superport_detail_dialog.dart';
|
||||
import '../../../domain/entities/approval_template.dart';
|
||||
import '../../../../auth/application/auth_service.dart';
|
||||
import '../../../shared/widgets/approver_autocomplete_field.dart';
|
||||
|
||||
/// 결재 템플릿 상세 다이얼로그에서 발생 가능한 액션 종류이다.
|
||||
enum ApprovalTemplateDetailAction { created, updated, deleted, restored }
|
||||
|
||||
/// 결재 템플릿 상세 다이얼로그 결과 모델이다.
|
||||
class ApprovalTemplateDetailResult {
|
||||
const ApprovalTemplateDetailResult({
|
||||
required this.action,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
final ApprovalTemplateDetailAction action;
|
||||
final String message;
|
||||
}
|
||||
|
||||
typedef ApprovalTemplateCreateCallback =
|
||||
Future<ApprovalTemplate?> Function(
|
||||
ApprovalTemplateInput input,
|
||||
List<ApprovalTemplateStepInput> steps,
|
||||
);
|
||||
|
||||
typedef ApprovalTemplateUpdateCallback =
|
||||
Future<ApprovalTemplate?> Function(
|
||||
int id,
|
||||
ApprovalTemplateInput input,
|
||||
List<ApprovalTemplateStepInput> steps,
|
||||
);
|
||||
|
||||
typedef ApprovalTemplateDeleteCallback = Future<bool> Function(int id);
|
||||
typedef ApprovalTemplateRestoreCallback =
|
||||
Future<ApprovalTemplate?> Function(int id);
|
||||
|
||||
/// 결재 템플릿 상세 다이얼로그를 표시한다.
|
||||
Future<ApprovalTemplateDetailResult?> showApprovalTemplateDetailDialog({
|
||||
required BuildContext context,
|
||||
required intl.DateFormat dateFormat,
|
||||
ApprovalTemplate? template,
|
||||
required ApprovalTemplateCreateCallback onCreate,
|
||||
required ApprovalTemplateUpdateCallback onUpdate,
|
||||
required ApprovalTemplateDeleteCallback onDelete,
|
||||
required ApprovalTemplateRestoreCallback onRestore,
|
||||
}) {
|
||||
final isCreate = template == null;
|
||||
final summaryBadges = <Widget>[
|
||||
if (template?.isActive == true)
|
||||
const ShadBadge(child: Text('사용'))
|
||||
else if (template != null)
|
||||
const ShadBadge.outline(child: Text('미사용')),
|
||||
];
|
||||
final metadata = template == null
|
||||
? const <SuperportDetailMetadata>[]
|
||||
: [
|
||||
SuperportDetailMetadata.text(label: 'ID', value: '${template.id}'),
|
||||
SuperportDetailMetadata.text(label: '코드', value: template.code),
|
||||
SuperportDetailMetadata.text(
|
||||
label: '상태',
|
||||
value: template.isActive ? '사용' : '미사용',
|
||||
),
|
||||
SuperportDetailMetadata.text(
|
||||
label: '생성일시',
|
||||
value: template.createdAt == null
|
||||
? '-'
|
||||
: dateFormat.format(template.createdAt!.toLocal()),
|
||||
),
|
||||
SuperportDetailMetadata.text(
|
||||
label: '변경일시',
|
||||
value: template.updatedAt == null
|
||||
? '-'
|
||||
: dateFormat.format(template.updatedAt!.toLocal()),
|
||||
),
|
||||
SuperportDetailMetadata.text(
|
||||
label: '비고',
|
||||
value: template.note?.isNotEmpty == true ? template.note! : '-',
|
||||
),
|
||||
];
|
||||
|
||||
final sections = <SuperportDetailDialogSection>[
|
||||
if (!isCreate)
|
||||
SuperportDetailDialogSection(
|
||||
id: _TemplateSections.steps,
|
||||
label: '단계',
|
||||
icon: lucide.LucideIcons.listTree,
|
||||
builder: (_) => _TemplateStepsSection(template: template),
|
||||
),
|
||||
SuperportDetailDialogSection(
|
||||
id: isCreate ? _TemplateSections.create : _TemplateSections.edit,
|
||||
label: isCreate ? '생성' : '수정',
|
||||
icon: lucide.LucideIcons.pencil,
|
||||
builder: (_) => _TemplateFormSection(
|
||||
template: template,
|
||||
onCreate: onCreate,
|
||||
onUpdate: onUpdate,
|
||||
),
|
||||
),
|
||||
if (!isCreate)
|
||||
SuperportDetailDialogSection(
|
||||
id: template.isActive
|
||||
? _TemplateSections.delete
|
||||
: _TemplateSections.restore,
|
||||
label: template.isActive ? '삭제' : '복구',
|
||||
icon: template.isActive
|
||||
? lucide.LucideIcons.trash2
|
||||
: lucide.LucideIcons.history,
|
||||
scrollable: false,
|
||||
builder: (_) => _TemplateDangerSection(
|
||||
template: template,
|
||||
onDelete: onDelete,
|
||||
onRestore: onRestore,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return showSuperportDetailDialog<ApprovalTemplateDetailResult>(
|
||||
context: context,
|
||||
title: isCreate ? '결재 템플릿 생성' : '결재 템플릿 상세',
|
||||
description: isCreate
|
||||
? '반복되는 결재 단계를 템플릿으로 등록합니다.'
|
||||
: '템플릿 정보를 확인하고 수정하거나 삭제/복구할 수 있습니다.',
|
||||
summary: template == null
|
||||
? null
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(template.name, style: ShadTheme.of(context).textTheme.h4),
|
||||
if (template.description?.isNotEmpty == true) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
template.description!,
|
||||
style: ShadTheme.of(context).textTheme.muted,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
summaryBadges: summaryBadges,
|
||||
metadata: metadata,
|
||||
sections: sections,
|
||||
initialSectionId: isCreate
|
||||
? _TemplateSections.create
|
||||
: _TemplateSections.steps,
|
||||
);
|
||||
}
|
||||
|
||||
/// 다이얼로그 섹션 식별자 상수 모음이다.
|
||||
class _TemplateSections {
|
||||
static const steps = 'steps';
|
||||
static const edit = 'edit';
|
||||
static const create = 'create';
|
||||
static const delete = 'delete';
|
||||
static const restore = 'restore';
|
||||
}
|
||||
|
||||
/// 템플릿 단계 목록을 표시하는 섹션이다.
|
||||
class _TemplateStepsSection extends StatelessWidget {
|
||||
const _TemplateStepsSection({required this.template});
|
||||
|
||||
final ApprovalTemplate template;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
if (template.steps.isEmpty) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 32),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.secondary.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: theme.colorScheme.border),
|
||||
),
|
||||
child: Center(
|
||||
child: Text('등록된 결재 단계가 없습니다.', style: theme.textTheme.muted),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final step in template.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),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 템플릿 등록/수정 폼 섹션이다.
|
||||
class _TemplateFormSection extends StatefulWidget {
|
||||
const _TemplateFormSection({
|
||||
required this.template,
|
||||
required this.onCreate,
|
||||
required this.onUpdate,
|
||||
});
|
||||
|
||||
final ApprovalTemplate? template;
|
||||
final ApprovalTemplateCreateCallback onCreate;
|
||||
final ApprovalTemplateUpdateCallback onUpdate;
|
||||
|
||||
@override
|
||||
State<_TemplateFormSection> createState() => _TemplateFormSectionState();
|
||||
}
|
||||
|
||||
class _TemplateFormSectionState extends State<_TemplateFormSection> {
|
||||
late final TextEditingController _codeController;
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _descriptionController;
|
||||
late final TextEditingController _noteController;
|
||||
late final ValueNotifier<bool> _isActiveNotifier;
|
||||
late final List<_TemplateStepField> _steps;
|
||||
bool _isSubmitting = false;
|
||||
String? _errorText;
|
||||
|
||||
bool get _isEdit => widget.template != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final template = widget.template;
|
||||
_codeController = TextEditingController(
|
||||
text: template?.code ?? _generateTemplateCode(),
|
||||
);
|
||||
_nameController = TextEditingController(text: template?.name ?? '');
|
||||
_descriptionController = TextEditingController(
|
||||
text: template?.description ?? '',
|
||||
);
|
||||
_noteController = TextEditingController(text: template?.note ?? '');
|
||||
_isActiveNotifier = ValueNotifier<bool>(template?.isActive ?? true);
|
||||
_steps = _buildInitialStepFields(template);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_codeController.dispose();
|
||||
_nameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
_noteController.dispose();
|
||||
_isActiveNotifier.dispose();
|
||||
for (final step in _steps) {
|
||||
step.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!_isEdit)
|
||||
_FormField(
|
||||
label: '템플릿 코드',
|
||||
child: ShadInput(
|
||||
controller: _codeController,
|
||||
readOnly: true,
|
||||
enabled: false,
|
||||
),
|
||||
),
|
||||
_FormField(
|
||||
label: '템플릿명',
|
||||
required: true,
|
||||
child: ShadInput(
|
||||
key: const ValueKey('template_form_name'),
|
||||
controller: _nameController,
|
||||
),
|
||||
),
|
||||
_FormField(
|
||||
label: '설명',
|
||||
child: ShadTextarea(
|
||||
key: const ValueKey('template_form_description'),
|
||||
controller: _descriptionController,
|
||||
minHeight: 80,
|
||||
maxHeight: 200,
|
||||
),
|
||||
),
|
||||
_FormField(
|
||||
label: '사용 여부',
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: _isActiveNotifier,
|
||||
builder: (_, value, __) {
|
||||
return Row(
|
||||
children: [
|
||||
ShadSwitch(
|
||||
value: value,
|
||||
onChanged: _isSubmitting
|
||||
? null
|
||||
: (next) => _isActiveNotifier.value = next,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(value ? '사용' : '미사용'),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
_FormField(
|
||||
label: '비고',
|
||||
child: ShadTextarea(
|
||||
key: const ValueKey('template_form_note'),
|
||||
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('template_step_$index'),
|
||||
field: _steps[index],
|
||||
index: index,
|
||||
isEdit: _isEdit,
|
||||
isDisabled: _isSubmitting,
|
||||
onRemove: _steps.length <= 1 || _isSubmitting
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
final removed = _steps.removeAt(index);
|
||||
removed.dispose();
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: ShadButton.outline(
|
||||
onPressed: _isSubmitting
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
_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) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorText!,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.destructive,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ShadButton(
|
||||
onPressed: _isSubmitting ? null : _handleSubmit,
|
||||
child: Text(_isSubmitting ? '저장 중...' : (_isEdit ? '저장' : '등록')),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<_TemplateStepField> _buildInitialStepFields(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 _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;
|
||||
}
|
||||
}
|
||||
final userId = session?.user.id;
|
||||
if (normalizedEmployee.isEmpty && userId != null) {
|
||||
normalizedEmployee = userId.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> _handleSubmit() async {
|
||||
final isEdit = _isEdit;
|
||||
final nameValue = _nameController.text.trim();
|
||||
if (nameValue.isEmpty) {
|
||||
setState(() => _errorText = '템플릿명을 입력하세요.');
|
||||
return;
|
||||
}
|
||||
final validation = _validateSteps(_steps);
|
||||
if (validation != null) {
|
||||
setState(() => _errorText = validation);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_errorText = null;
|
||||
_isSubmitting = true;
|
||||
});
|
||||
|
||||
final steps = _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 existingTemplate = widget.template;
|
||||
final input = ApprovalTemplateInput(
|
||||
code: isEdit && existingTemplate != null
|
||||
? existingTemplate.code
|
||||
: _codeController.text.trim(),
|
||||
name: nameValue,
|
||||
description: _descriptionController.text.trim().isEmpty
|
||||
? null
|
||||
: _descriptionController.text.trim(),
|
||||
note: _noteController.text.trim().isEmpty
|
||||
? null
|
||||
: _noteController.text.trim(),
|
||||
isActive: _isActiveNotifier.value,
|
||||
);
|
||||
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
ApprovalTemplate? result;
|
||||
|
||||
try {
|
||||
if (isEdit && existingTemplate != null) {
|
||||
result = await widget.onUpdate(existingTemplate.id, input, steps);
|
||||
if (result != null && navigator.mounted) {
|
||||
navigator.pop(
|
||||
ApprovalTemplateDetailResult(
|
||||
action: ApprovalTemplateDetailAction.updated,
|
||||
message: '템플릿 "${result.name}"을(를) 수정했습니다.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
result = await widget.onCreate(input, steps);
|
||||
if (result != null && navigator.mounted) {
|
||||
navigator.pop(
|
||||
ApprovalTemplateDetailResult(
|
||||
action: ApprovalTemplateDetailAction.created,
|
||||
message: '템플릿 "${result.name}"을(를) 생성했습니다.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setState(() {
|
||||
_errorText = '요청 처리에 실패했습니다. 입력값을 확인한 뒤 다시 시도하세요.';
|
||||
_isSubmitting = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorText = '요청 처리 중 오류가 발생했습니다. 다시 시도하세요.';
|
||||
_isSubmitting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String? _validateSteps(List<_TemplateStepField> fields) {
|
||||
if (fields.isEmpty) {
|
||||
return '최소 1개 이상의 결재 단계를 입력하세요.';
|
||||
}
|
||||
final orders = <int>{};
|
||||
for (final field in fields) {
|
||||
final order = int.tryParse(field.orderController.text.trim());
|
||||
final approver = int.tryParse(field.approverController.text.trim());
|
||||
if (order == null || order <= 0) {
|
||||
return '모든 단계의 순서를 1 이상의 숫자로 입력하세요.';
|
||||
}
|
||||
if (approver == null || approver <= 0) {
|
||||
return '모든 단계의 승인자 ID를 1 이상의 숫자로 입력하세요.';
|
||||
}
|
||||
if (!orders.add(order)) {
|
||||
return '단계 순서는 중복될 수 없습니다.';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 템플릿 삭제/복구 섹션이다.
|
||||
class _TemplateDangerSection extends StatelessWidget {
|
||||
const _TemplateDangerSection({
|
||||
required this.template,
|
||||
required this.onDelete,
|
||||
required this.onRestore,
|
||||
});
|
||||
|
||||
final ApprovalTemplate template;
|
||||
final ApprovalTemplateDeleteCallback onDelete;
|
||||
final ApprovalTemplateRestoreCallback onRestore;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
final isActive = template.isActive;
|
||||
|
||||
Future<void> handleAction(
|
||||
Future<ApprovalTemplate?> Function() callback,
|
||||
ApprovalTemplateDetailAction action,
|
||||
String message,
|
||||
) async {
|
||||
final result = await callback();
|
||||
if (result != null && navigator.mounted) {
|
||||
navigator.pop(
|
||||
ApprovalTemplateDetailResult(action: action, message: message),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isActive
|
||||
? '삭제하면 템플릿은 미사용 상태로 전환됩니다. 필요 시 복구할 수 있습니다.'
|
||||
: '복구하면 템플릿이 다시 사용 상태로 전환됩니다.',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (isActive)
|
||||
ShadButton.destructive(
|
||||
onPressed: () => handleAction(
|
||||
() async {
|
||||
final success = await onDelete(template.id);
|
||||
return success ? template.copyWith(isActive: false) : null;
|
||||
},
|
||||
ApprovalTemplateDetailAction.deleted,
|
||||
'템플릿 "${template.name}"을(를) 삭제했습니다.',
|
||||
),
|
||||
child: const Text('삭제'),
|
||||
)
|
||||
else
|
||||
ShadButton(
|
||||
onPressed: () => handleAction(
|
||||
() async => onRestore(template.id),
|
||||
ApprovalTemplateDetailAction.restored,
|
||||
'템플릿 "${template.name}"을(를) 복구했습니다.',
|
||||
),
|
||||
child: const Text('복구'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 폼 필드 레이아웃 위젯이다.
|
||||
class _FormField extends StatelessWidget {
|
||||
const _FormField({
|
||||
required this.label,
|
||||
required this.child,
|
||||
this.required = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Widget child;
|
||||
final bool required;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (required) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text('*', style: theme.textTheme.small),
|
||||
],
|
||||
],
|
||||
),
|
||||
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,
|
||||
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(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.border.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ShadInput(
|
||||
key: ValueKey('template_step_${index}_order'),
|
||||
controller: field.orderController,
|
||||
keyboardType: TextInputType.number,
|
||||
placeholder: const Text('단계 순서'),
|
||||
enabled: !isDisabled,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: IgnorePointer(
|
||||
ignoring: isDisabled,
|
||||
child: ApprovalApproverAutocompleteField(
|
||||
key: ValueKey('template_step_${index}_approver'),
|
||||
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;
|
||||
|
||||
factory _TemplateStepField.create({required int order}) {
|
||||
return _TemplateStepField(
|
||||
orderController: TextEditingController(text: '$order'),
|
||||
approverController: TextEditingController(),
|
||||
noteController: TextEditingController(),
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
orderController.dispose();
|
||||
approverController.dispose();
|
||||
noteController.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// 단순 레이블/값 모델이다.
|
||||
Reference in New Issue
Block a user