feat(dialog): 상세 팝업 SuperportDetailDialog 통합

- SuperportDetailDialog 위젯과 showSuperportDetailDialog 헬퍼를 추가하고 metadata/섹션 패턴을 표준화

- 결재/재고/마스터 각 상세 다이얼로그를 dialogs 디렉터리에 신설하고 기존 페이지를 신규 팝업으로 전환

- SuperportTable 행 선택과 우편번호 검색 다이얼로그 onRowTap 보정을 통해 헤더 오프셋 버그를 제거

- 상세 다이얼로그 및 트랜잭션/상세 뷰 전용 위젯 테스트와 tester_extensions 유틸을 추가하여 회귀를 방지

- detail_dialog_unification_plan.md로 작업 배경과 필드 통합 계획을 문서화
This commit is contained in:
JiWoong Sul
2025-11-07 19:02:43 +09:00
parent 1f78171294
commit 2f8b529506
64 changed files with 13721 additions and 7545 deletions

View File

@@ -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();
}
}
/// 단순 레이블/값 모델이다.

View File

@@ -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(),
);
}
}