결재 템플릿 CRUD 화면과 컨트롤러 정식화

This commit is contained in:
JiWoong Sul
2025-09-25 00:42:25 +09:00
parent c3010965ad
commit 1fbed565b7
10 changed files with 1450 additions and 53 deletions

View File

@@ -1,3 +1,5 @@
import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../../domain/entities/approval_template.dart';
class ApprovalTemplateDto {
@@ -7,6 +9,7 @@ class ApprovalTemplateDto {
required this.name,
this.description,
required this.isActive,
this.note,
this.createdBy,
this.createdAt,
this.updatedAt,
@@ -18,6 +21,7 @@ class ApprovalTemplateDto {
final String name;
final String? description;
final bool isActive;
final String? note;
final ApprovalTemplateAuthorDto? createdBy;
final DateTime? createdAt;
final DateTime? updatedAt;
@@ -29,6 +33,7 @@ class ApprovalTemplateDto {
code: json['template_code'] as String? ?? json['code'] as String? ?? '-',
name: json['template_name'] as String? ?? json['name'] as String? ?? '-',
description: json['description'] as String?,
note: json['note'] as String?,
isActive: (json['is_active'] as bool?) ?? true,
createdBy: json['created_by'] is Map<String, dynamic>
? ApprovalTemplateAuthorDto.fromJson(
@@ -50,6 +55,7 @@ class ApprovalTemplateDto {
code: code,
name: name,
description: description,
note: note,
isActive: isActive,
createdBy: createdBy?.toEntity(),
createdAt: createdAt,
@@ -57,6 +63,23 @@ class ApprovalTemplateDto {
steps: includeSteps ? steps.map((e) => e.toEntity()).toList() : const [],
);
}
static PaginatedResult<ApprovalTemplate> parsePaginated(
Map<String, dynamic>? json, {
bool includeSteps = false,
}) {
final items = (json?['items'] as List<dynamic>? ?? [])
.whereType<Map<String, dynamic>>()
.map(ApprovalTemplateDto.fromJson)
.map((dto) => dto.toEntity(includeSteps: includeSteps))
.toList();
return PaginatedResult<ApprovalTemplate>(
items: items,
page: json?['page'] as int? ?? 1,
pageSize: json?['page_size'] as int? ?? items.length,
total: json?['total'] as int? ?? items.length,
);
}
}
class ApprovalTemplateAuthorDto {

View File

@@ -1,5 +1,6 @@
import 'package:dio/dio.dart';
import '../../../../core/common/models/paginated_result.dart';
import '../../../../core/network/api_client.dart';
import '../../domain/entities/approval_template.dart';
import '../../domain/repositories/approval_template_repository.dart';
@@ -14,18 +15,23 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
static const _basePath = '/approval-templates';
@override
Future<List<ApprovalTemplate>> list({bool activeOnly = true}) async {
Future<PaginatedResult<ApprovalTemplate>> list({
int page = 1,
int pageSize = 20,
String? query,
bool? isActive,
}) async {
final response = await _api.get<Map<String, dynamic>>(
_basePath,
query: {'page': 1, 'page_size': 100, if (activeOnly) 'active': true},
query: {
'page': page,
'page_size': pageSize,
if (query != null && query.isNotEmpty) 'q': query,
if (isActive != null) 'active': isActive,
},
options: Options(responseType: ResponseType.json),
);
final items = (response.data?['items'] as List<dynamic>? ?? [])
.whereType<Map<String, dynamic>>()
.map(ApprovalTemplateDto.fromJson)
.map((dto) => dto.toEntity(includeSteps: false))
.toList();
return items;
return ApprovalTemplateDto.parsePaginated(response.data);
}
@override
@@ -43,4 +49,85 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
data,
).toEntity(includeSteps: includeSteps);
}
@override
Future<ApprovalTemplate> create(
ApprovalTemplateInput input, {
List<ApprovalTemplateStepInput> steps = const [],
}) async {
final response = await _api.post<Map<String, dynamic>>(
_basePath,
data: input.toCreatePayload(),
options: Options(responseType: ResponseType.json),
);
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
final created = ApprovalTemplateDto.fromJson(
data,
).toEntity(includeSteps: false);
if (steps.isNotEmpty) {
await _postSteps(created.id, steps);
}
return fetchDetail(created.id, includeSteps: true);
}
@override
Future<ApprovalTemplate> update(
int id,
ApprovalTemplateInput input, {
List<ApprovalTemplateStepInput>? steps,
}) async {
await _api.patch<Map<String, dynamic>>(
'$_basePath/$id',
data: input.toUpdatePayload(id),
options: Options(responseType: ResponseType.json),
);
if (steps != null) {
await _patchSteps(id, steps);
}
return fetchDetail(id, includeSteps: true);
}
@override
Future<void> delete(int id) async {
await _api.delete<void>('$_basePath/$id');
}
@override
Future<ApprovalTemplate> restore(int id) async {
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/$id/restore',
options: Options(responseType: ResponseType.json),
);
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
return ApprovalTemplateDto.fromJson(data).toEntity(includeSteps: false);
}
Future<void> _postSteps(
int templateId,
List<ApprovalTemplateStepInput> steps,
) async {
if (steps.isEmpty) return;
await _api.post<Map<String, dynamic>>(
'$_basePath/$templateId/steps',
data: {
'id': templateId,
'steps': steps.map((step) => step.toJson(includeId: false)).toList(),
},
options: Options(responseType: ResponseType.json),
);
}
Future<void> _patchSteps(
int templateId,
List<ApprovalTemplateStepInput> steps,
) async {
await _api.patch<Map<String, dynamic>>(
'$_basePath/$templateId/steps',
data: {
'id': templateId,
'steps': steps.map((step) => step.toJson()).toList(),
},
options: Options(responseType: ResponseType.json),
);
}
}

View File

@@ -7,6 +7,7 @@ class ApprovalTemplate {
required this.code,
required this.name,
this.description,
this.note,
required this.isActive,
this.createdBy,
this.createdAt,
@@ -18,11 +19,37 @@ class ApprovalTemplate {
final String code;
final String name;
final String? description;
final String? note;
final bool isActive;
final ApprovalTemplateAuthor? createdBy;
final DateTime? createdAt;
final DateTime? updatedAt;
final List<ApprovalTemplateStep> steps;
ApprovalTemplate copyWith({
String? code,
String? name,
String? description,
String? note,
bool? isActive,
ApprovalTemplateAuthor? createdBy,
DateTime? createdAt,
DateTime? updatedAt,
List<ApprovalTemplateStep>? steps,
}) {
return ApprovalTemplate(
id: id,
code: code ?? this.code,
name: name ?? this.name,
description: description ?? this.description,
note: note ?? this.note,
isActive: isActive ?? this.isActive,
createdBy: createdBy ?? this.createdBy,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
steps: steps ?? this.steps,
);
}
}
class ApprovalTemplateAuthor {
@@ -62,3 +89,73 @@ class ApprovalTemplateApprover {
final String employeeNo;
final String name;
}
/// 결재 템플릿 생성/수정 입력 모델
class ApprovalTemplateInput {
ApprovalTemplateInput({
this.code,
required this.name,
this.description,
this.note,
this.isActive = true,
this.createdById,
});
final String? code;
final String name;
final String? description;
final String? note;
final bool isActive;
final int? createdById;
Map<String, dynamic> toCreatePayload() {
return {
if (code != null) 'template_code': code,
'template_name': name,
if (description != null && description!.trim().isNotEmpty)
'description': description,
if (note != null && note!.trim().isNotEmpty) 'note': note,
'is_active': isActive,
if (createdById != null) 'created_by_id': createdById,
};
}
Map<String, dynamic> toUpdatePayload(int id) {
final payload = <String, dynamic>{
'id': id,
'template_name': name,
'is_active': isActive,
};
if (description != null && description!.trim().isNotEmpty) {
payload['description'] = description;
}
if (note != null && note!.trim().isNotEmpty) {
payload['note'] = note;
}
return payload;
}
}
/// 템플릿 단계 입력 모델
class ApprovalTemplateStepInput {
ApprovalTemplateStepInput({
this.id,
required this.stepOrder,
required this.approverId,
this.note,
});
final int? id;
final int stepOrder;
final int approverId;
final String? note;
Map<String, dynamic> toJson({bool includeId = true}) {
return {
if (includeId && id != null) 'id': id,
'step_order': stepOrder,
'approver_id': approverId,
if (note != null && note!.trim().isNotEmpty) 'note': note,
};
}
}

View File

@@ -1,7 +1,29 @@
import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../entities/approval_template.dart';
abstract class ApprovalTemplateRepository {
Future<List<ApprovalTemplate>> list({bool activeOnly = true});
Future<PaginatedResult<ApprovalTemplate>> list({
int page = 1,
int pageSize = 20,
String? query,
bool? isActive,
});
Future<ApprovalTemplate> fetchDetail(int id, {bool includeSteps = true});
Future<ApprovalTemplate> create(
ApprovalTemplateInput input, {
List<ApprovalTemplateStepInput> steps = const [],
});
Future<ApprovalTemplate> update(
int id,
ApprovalTemplateInput input, {
List<ApprovalTemplateStepInput>? steps,
});
Future<void> delete(int id);
Future<ApprovalTemplate> restore(int id);
}

View File

@@ -138,8 +138,12 @@ class ApprovalController extends ChangeNotifier {
_errorMessage = null;
notifyListeners();
try {
final items = await _templateRepository.list(activeOnly: true);
_templates = items;
final result = await _templateRepository.list(
page: 1,
pageSize: 100,
isActive: true,
);
_templates = result.items;
} catch (e) {
_errorMessage = e.toString();
} finally {

View File

@@ -0,0 +1,167 @@
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../../../domain/entities/approval_template.dart';
import '../../../domain/repositories/approval_template_repository.dart';
enum ApprovalTemplateStatusFilter { all, activeOnly, inactiveOnly }
/// 결재 템플릿 화면 상태 컨트롤러
///
/// - 목록/검색/필터 상태와 생성·수정·삭제 요청을 관리한다.
class ApprovalTemplateController extends ChangeNotifier {
ApprovalTemplateController({required ApprovalTemplateRepository repository})
: _repository = repository;
final ApprovalTemplateRepository _repository;
PaginatedResult<ApprovalTemplate>? _result;
bool _isLoading = false;
bool _isSubmitting = false;
String _query = '';
ApprovalTemplateStatusFilter _statusFilter = ApprovalTemplateStatusFilter.all;
String? _errorMessage;
PaginatedResult<ApprovalTemplate>? get result => _result;
bool get isLoading => _isLoading;
bool get isSubmitting => _isSubmitting;
String get query => _query;
ApprovalTemplateStatusFilter get statusFilter => _statusFilter;
String? get errorMessage => _errorMessage;
Future<void> fetch({int page = 1}) async {
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
final sanitizedQuery = _query.trim();
final isActive = switch (_statusFilter) {
ApprovalTemplateStatusFilter.all => null,
ApprovalTemplateStatusFilter.activeOnly => true,
ApprovalTemplateStatusFilter.inactiveOnly => false,
};
final response = await _repository.list(
page: page,
pageSize: _result?.pageSize ?? 20,
query: sanitizedQuery.isEmpty ? null : sanitizedQuery,
isActive: isActive,
);
_result = response;
} catch (e) {
_errorMessage = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
void updateQuery(String value) {
_query = value;
notifyListeners();
}
void updateStatusFilter(ApprovalTemplateStatusFilter filter) {
_statusFilter = filter;
notifyListeners();
}
Future<ApprovalTemplate?> fetchDetail(int id) async {
_errorMessage = null;
notifyListeners();
try {
final detail = await _repository.fetchDetail(id, includeSteps: true);
return detail;
} catch (e) {
_errorMessage = e.toString();
notifyListeners();
return null;
}
}
Future<ApprovalTemplate?> create(
ApprovalTemplateInput input,
List<ApprovalTemplateStepInput> steps,
) async {
_setSubmitting(true);
try {
final created = await _repository.create(input, steps: steps);
await fetch(page: 1);
return created;
} catch (e) {
_errorMessage = e.toString();
notifyListeners();
return null;
} finally {
_setSubmitting(false);
}
}
Future<ApprovalTemplate?> update(
int id,
ApprovalTemplateInput input,
List<ApprovalTemplateStepInput>? steps,
) async {
_setSubmitting(true);
try {
final updated = await _repository.update(id, input, steps: steps);
await fetch(page: _result?.page ?? 1);
return updated;
} catch (e) {
_errorMessage = e.toString();
notifyListeners();
return null;
} finally {
_setSubmitting(false);
}
}
Future<bool> delete(int id) async {
_setSubmitting(true);
try {
await _repository.delete(id);
await fetch(page: _result?.page ?? 1);
return true;
} catch (e) {
_errorMessage = e.toString();
notifyListeners();
return false;
} finally {
_setSubmitting(false);
}
}
Future<ApprovalTemplate?> restore(int id) async {
_setSubmitting(true);
try {
final restored = await _repository.restore(id);
await fetch(page: _result?.page ?? 1);
return restored;
} catch (e) {
_errorMessage = e.toString();
notifyListeners();
return null;
} finally {
_setSubmitting(false);
}
}
void clearError() {
_errorMessage = null;
notifyListeners();
}
bool get hasActiveFilters =>
_query.trim().isNotEmpty ||
_statusFilter != ApprovalTemplateStatusFilter.all;
void resetFilters() {
_query = '';
_statusFilter = ApprovalTemplateStatusFilter.all;
notifyListeners();
}
void _setSubmitting(bool value) {
_isSubmitting = value;
notifyListeners();
}
}

View File

@@ -1,12 +1,18 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../../core/config/environment.dart';
import '../../../../../core/constants/app_sections.dart';
import '../../../../../widgets/app_layout.dart';
import '../../../../../widgets/components/coming_soon_card.dart';
import '../../../../../widgets/components/filter_bar.dart';
import '../../../../../widgets/components/superport_dialog.dart';
import '../../../../../widgets/spec_page.dart';
import '../../../domain/entities/approval_template.dart';
import '../../../domain/repositories/approval_template_repository.dart';
import '../controllers/approval_template_controller.dart';
class ApprovalTemplatePage extends StatelessWidget {
const ApprovalTemplatePage({super.key});
@@ -17,7 +23,7 @@ class ApprovalTemplatePage extends StatelessWidget {
if (!enabled) {
return const SpecPage(
title: '결재 템플릿 관리',
summary: '반복적인 결재 흐름을 템플릿으로 정의합니다.',
summary: '반복되는 결재 단계를 템플릿으로 구성합니다.',
sections: [
SpecSection(
title: '입력 폼',
@@ -25,10 +31,9 @@ class ApprovalTemplatePage extends StatelessWidget {
'템플릿코드 [Text]',
'템플릿명 [Text]',
'설명 [Text]',
'작성자 [ReadOnly]',
'사용여부 [Switch]',
'비고 [Text]',
'단계 추가: 순서 [Number], 승인자 [Dropdown]',
'비고 [Textarea]',
'단계 추가: 순서 [Number], 승인자ID [Number]',
],
),
SpecSection(
@@ -39,16 +44,15 @@ class ApprovalTemplatePage extends StatelessWidget {
title: '테이블 리스트',
description: '1행 예시',
table: SpecTable(
columns: ['번호', '템플릿코드', '템플릿명', '설명', '작성자', '사용여부', '변경일시'],
columns: ['번호', '템플릿코드', '템플릿명', '설명', '사용여부', '변경일시'],
rows: [
[
'1',
'TEMP-001',
'입고 기본 결재',
'입고 처리 2단계 결재',
'홍길동',
'AP_INBOUND',
'입고 결재 기본',
'입고 2단계',
'Y',
'2024-03-01 10:00',
'2024-04-01 09:00',
],
],
),
@@ -57,29 +61,796 @@ class ApprovalTemplatePage extends StatelessWidget {
);
}
return AppLayout(
title: '결재 템플릿 관리',
subtitle: '반복되는 결재 단계를 템플릿으로 구성할 수 있도록 준비 중입니다.',
breadcrumbs: const [
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
AppBreadcrumbItem(label: '결재', path: '/approvals/templates'),
AppBreadcrumbItem(label: '결재 템플릿'),
],
return const _ApprovalTemplateEnabledPage();
}
}
class _ApprovalTemplateEnabledPage extends StatefulWidget {
const _ApprovalTemplateEnabledPage();
@override
State<_ApprovalTemplateEnabledPage> createState() =>
_ApprovalTemplateEnabledPageState();
}
class _ApprovalTemplateEnabledPageState
extends State<_ApprovalTemplateEnabledPage> {
late final ApprovalTemplateController _controller;
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocus = FocusNode();
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm');
String? _lastError;
@override
void initState() {
super.initState();
_controller = ApprovalTemplateController(
repository: GetIt.I<ApprovalTemplateRepository>(),
)..addListener(_handleControllerUpdate);
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _controller.fetch();
});
}
void _handleControllerUpdate() {
final error = _controller.errorMessage;
if (error != null && error != _lastError && mounted) {
_lastError = error;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error)));
_controller.clearError();
}
}
@override
void dispose() {
_controller.removeListener(_handleControllerUpdate);
_controller.dispose();
_searchController.dispose();
_searchFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final result = _controller.result;
final templates = result?.items ?? const <ApprovalTemplate>[];
final totalCount = result?.total ?? 0;
final currentPage = result?.page ?? 1;
final totalPages = result == null || result.pageSize == 0
? 1
: (result.total / result.pageSize).ceil().clamp(1, 9999);
final hasNext = result == null
? false
: (result.page * result.pageSize) < result.total;
final showReset =
_searchController.text.trim().isNotEmpty ||
_controller.statusFilter != ApprovalTemplateStatusFilter.all;
return AppLayout(
title: '결재 템플릿 관리',
subtitle: '결재 단계 구성을 템플릿으로 저장하고 재사용합니다.',
breadcrumbs: const [
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
AppBreadcrumbItem(label: '결재', path: '/approvals/templates'),
AppBreadcrumbItem(label: '결재 템플릿'),
],
actions: [
ShadButton(
leading: const Icon(lucide.LucideIcons.plus, size: 16),
onPressed: _controller.isSubmitting
? null
: () => _openTemplateForm(),
child: const Text('템플릿 생성'),
),
],
toolbar: FilterBar(
children: [
SizedBox(
width: 260,
child: ShadInput(
controller: _searchController,
focusNode: _searchFocus,
placeholder: const Text('템플릿코드, 템플릿명 검색'),
leading: const Icon(lucide.LucideIcons.search, size: 16),
onSubmitted: (_) => _applyFilters(),
),
),
SizedBox(
width: 180,
child: ShadSelect<ApprovalTemplateStatusFilter>(
key: ValueKey(_controller.statusFilter),
initialValue: _controller.statusFilter,
selectedOptionBuilder: (context, value) =>
Text(_statusLabel(value)),
onChanged: (value) {
if (value == null) return;
_controller.updateStatusFilter(value);
},
options: ApprovalTemplateStatusFilter.values
.map(
(filter) => ShadOption(
value: filter,
child: Text(_statusLabel(filter)),
),
)
.toList(),
),
),
ShadButton.outline(
onPressed: _controller.isLoading ? null : _applyFilters,
child: const Text('검색 적용'),
),
ShadButton.ghost(
onPressed: !_controller.isLoading && showReset
? _resetFilters
: null,
child: const Text('필터 초기화'),
),
],
),
child: ShadCard(
child: _controller.isLoading
? const Padding(
padding: EdgeInsets.all(48),
child: Center(child: CircularProgressIndicator()),
)
: templates.isEmpty
? Padding(
padding: const EdgeInsets.all(32),
child: Text(
'등록된 결재 템플릿이 없습니다.',
style: theme.textTheme.muted,
),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: ShadTable.list(
header:
['ID', '템플릿코드', '템플릿명', '설명', '사용', '변경일시', '동작']
.map(
(e) => ShadTableCell.header(child: Text(e)),
)
.toList(),
columnSpanExtent: (index) {
switch (index) {
case 2:
return const FixedTableSpanExtent(220);
case 3:
return const FixedTableSpanExtent(260);
case 4:
return const FixedTableSpanExtent(100);
case 5:
return const FixedTableSpanExtent(180);
case 6:
return const FixedTableSpanExtent(160);
default:
return const FixedTableSpanExtent(140);
}
},
children: templates.map((template) {
return [
ShadTableCell(child: Text('${template.id}')),
ShadTableCell(child: Text(template.code)),
ShadTableCell(child: Text(template.name)),
ShadTableCell(
child: Text(
template.description?.isNotEmpty == true
? template.description!
: '-',
),
),
ShadTableCell(
child: ShadBadge(
variant: template.isActive
? ShadBadgeVariant.defaultVariant
: ShadBadgeVariant.outline,
child: Text(template.isActive ? '사용' : '미사용'),
),
),
ShadTableCell(
child: Text(
template.updatedAt == null
? '-'
: _dateFormat.format(
template.updatedAt!.toLocal(),
),
),
),
ShadTableCell(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ShadButton.ghost(
key: ValueKey(
'template_edit_${template.id}',
),
size: ShadButtonSize.sm,
onPressed: _controller.isSubmitting
? null
: () => _openEditTemplate(template),
child: const Text('수정'),
),
const SizedBox(width: 8),
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(),
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('$totalCount건', style: theme.textTheme.small),
Row(
children: [
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed:
_controller.isLoading || currentPage <= 1
? null
: () => _controller.fetch(
page: currentPage - 1,
),
child: const Text('이전'),
),
const SizedBox(width: 8),
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: _controller.isLoading || !hasNext
? null
: () => _controller.fetch(
page: currentPage + 1,
),
child: const Text('다음'),
),
const SizedBox(width: 12),
Text(
'페이지 $currentPage / $totalPages',
style: theme.textTheme.small,
),
],
),
],
),
],
),
),
);
},
);
}
void _applyFilters() {
_controller.updateQuery(_searchController.text.trim());
_controller.fetch(page: 1);
}
void _resetFilters() {
_searchController.clear();
_controller.resetFilters();
_controller.fetch(page: 1);
_searchFocus.requestFocus();
}
Future<void> _openEditTemplate(ApprovalTemplate template) async {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
final detail = await _controller.fetchDetail(template.id);
if (mounted) {
Navigator.of(context, rootNavigator: true).pop();
}
if (!mounted || detail == null) {
return;
}
final success = await _openTemplateForm(template: detail);
if (!mounted || success != true) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('템플릿 "${detail.name}"을(를) 수정했습니다.')));
}
Future<void> _confirmDelete(ApprovalTemplate template) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('템플릿 삭제'),
content: Text(
'"${template.name}" 템플릿을 삭제하시겠습니까?\n삭제 시 템플릿은 미사용 상태로 전환됩니다.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('취소'),
),
FilledButton.tonal(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('삭제'),
),
],
);
},
);
if (confirmed != true) return;
final ok = await _controller.delete(template.id);
if (!mounted || !ok) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('템플릿 "${template.name}"을(를) 삭제했습니다.')),
);
}
Future<void> _confirmRestore(ApprovalTemplate template) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('템플릿 복구'),
content: Text('"${template.name}" 템플릿을 복구하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('취소'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('복구'),
),
],
);
},
);
if (confirmed != true) return;
final restored = await _controller.restore(template.id);
if (!mounted || restored == null) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('템플릿 "${restored.name}"을(를) 복구했습니다.')),
);
}
Future<bool?> _openTemplateForm({ApprovalTemplate? template}) async {
final isEdit = template != null;
final codeController = TextEditingController(text: template?.code ?? '');
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;
final result = await showSuperportDialog<bool>(
context: context,
title: isEdit ? '템플릿 수정' : '템플릿 생성',
barrierDismissible: !isSaving,
body: StatefulBuilder(
builder: (dialogContext, 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,
placeholder: const Text('예: AP_INBOUND'),
),
),
_FormField(
label: '템플릿명',
child: ShadInput(controller: nameController),
),
_FormField(
label: '설명',
child: ShadTextarea(
controller: descriptionController,
minLines: 2,
maxLines: 4,
),
),
_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,
minLines: 2,
maxLines: 4,
),
),
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,
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: isSaving ? null : () => Navigator.of(context).pop(false),
child: const Text('취소'),
),
ShadButton(
onPressed: null,
leading: const Icon(LucideIcons.plus, size: 16),
child: const Text('템플릿 생성'),
onPressed: isSaving
? null
: () async {
final codeValue = codeController.text.trim();
final nameValue = nameController.text.trim();
if (!isEdit && codeValue.isEmpty) {
setModalState(() => errorText = '템플릿 코드를 입력하세요.');
return;
}
if (nameValue.isEmpty) {
setModalState(() => errorText = '템플릿명을 입력하세요.');
return;
}
final validation = _validateSteps(steps);
if (validation != null) {
setModalState(() => errorText = validation);
return;
}
setModalState(() => 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 ? template!.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,
);
setModalState(() => isSaving = true);
final success = isEdit
? await _controller.update(
template!.id,
input,
stepInputs,
)
: await _controller.create(input, stepInputs);
if (success != null && mounted) {
Navigator.of(context).pop(true);
} else {
setModalState(() => isSaving = false);
}
},
child: isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(isEdit ? '수정 완료' : '생성 완료'),
),
],
toolbar: FilterBar(
children: const [Text('템플릿 검색/필터 UI는 결재 요구사항 확정 후 제공됩니다.')],
),
child: const ComingSoonCard(
title: '결재 템플릿 화면 구현 준비 중',
description: '템플릿 헤더 정보와 단계 반복 입력을 다루는 UI를 설계하고 있습니다.',
items: ['템플릿 목록 정렬 및 사용여부 토글', '단계 편집/추가/삭제 인터랙션', '템플릿 버전 관리 및 배포 전략'],
);
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) {
ScaffoldMessenger.of(
context,
).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 '미사용만';
}
}
}
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.onRemove,
});
final _TemplateStepField field;
final int index;
final bool isEdit;
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.withOpacity(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('단계 순서'),
),
),
const SizedBox(width: 12),
Expanded(
child: ShadInput(
controller: field.approverController,
keyboardType: TextInputType.number,
placeholder: const Text('승인자 ID'),
),
),
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),
ShadTextarea(
controller: field.noteController,
minLines: 1,
maxLines: 3,
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(),
);
}
}