결재 템플릿 단계 적용 구현

- ApprovalTemplate 엔티티·DTO·원격 리포지토리 추가
- ApprovalController에 템플릿 로딩/적용 상태와 assignSteps 호출 연동
- ApprovalPage 단계 탭에 템플릿 선택 UI 및 적용 확인 다이얼로그 구현
- 템플릿 적용 단위 테스트와 IMPLEMENTATION_TASKS 현황 갱신
This commit is contained in:
JiWoong Sul
2025-09-25 00:21:12 +09:00
parent b6e50464d2
commit c3010965ad
63 changed files with 10179 additions and 1436 deletions

133
lib/widgets/app_layout.dart Normal file
View File

@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'components/page_header.dart';
/// 앱 공통 레이아웃: 브레드크럼/헤더/툴바/본문을 일관되게 배치한다.
class AppLayout extends StatelessWidget {
const AppLayout({
super.key,
required this.title,
this.subtitle,
this.breadcrumbs = const <AppBreadcrumbItem>[],
this.actions,
this.toolbar,
required this.child,
});
final String title;
final String? subtitle;
final List<AppBreadcrumbItem> breadcrumbs;
final List<Widget>? actions;
final Widget? toolbar;
final Widget child;
@override
Widget build(BuildContext context) {
return SelectionArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (breadcrumbs.isNotEmpty) ...[
_BreadcrumbBar(items: breadcrumbs),
const SizedBox(height: 16),
],
PageHeader(
title: title,
subtitle: subtitle,
actions: actions,
),
if (toolbar != null) ...[
const SizedBox(height: 16),
toolbar!,
],
const SizedBox(height: 24),
child,
],
),
),
);
}
}
class AppBreadcrumbItem {
const AppBreadcrumbItem({
required this.label,
this.path,
this.onTap,
});
final String label;
final String? path;
final VoidCallback? onTap;
void navigate(BuildContext context) {
if (path != null && path!.isNotEmpty) {
context.go(path!);
return;
}
onTap?.call();
}
}
class _BreadcrumbBar extends StatelessWidget {
const _BreadcrumbBar({required this.items});
final List<AppBreadcrumbItem> items;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final colorScheme = theme.colorScheme;
return Wrap(
spacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (int index = 0; index < items.length; index++) ...[
if (index != 0)
Icon(
LucideIcons.chevronRight,
size: 14,
color: colorScheme.mutedForeground,
),
_BreadcrumbChip(item: items[index], isLast: index == items.length - 1),
],
],
);
}
}
class _BreadcrumbChip extends StatelessWidget {
const _BreadcrumbChip({required this.item, required this.isLast});
final AppBreadcrumbItem item;
final bool isLast;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final label = Text(
item.label,
style: theme.textTheme.small.copyWith(
color: isLast ? theme.colorScheme.foreground : theme.colorScheme.mutedForeground,
),
);
if (isLast || (item.path == null && item.onTap == null)) {
return label;
}
return InkWell(
onTap: () => item.navigate(context),
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: label,
),
);
}
}