결재 템플릿 단계 적용 구현

- 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

View File

@@ -0,0 +1,49 @@
import 'package:flutter/widgets.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
/// 구축 예정인 화면에 안내 메시지를 제공하는 카드 위젯.
class ComingSoonCard extends StatelessWidget {
const ComingSoonCard({
super.key,
required this.title,
required this.description,
this.items = const <String>[],
});
final String title;
final String description;
final List<String> items;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return ShadCard(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.h3),
const SizedBox(height: 12),
Text(description, style: theme.textTheme.p),
if (items.isNotEmpty) ...[
const SizedBox(height: 16),
for (final item in items)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(''),
Expanded(child: Text(item, style: theme.textTheme.p)),
],
),
),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
class EmptyState extends StatelessWidget {
const EmptyState({super.key, required this.message, this.icon});
final String message;
final IconData? icon;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null)
Icon(icon, size: 48, color: theme.colorScheme.mutedForeground),
if (icon != null) const SizedBox(height: 16),
Text(message, style: theme.textTheme.muted),
],
),
);
}
}

View File

@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
/// 검색/필터 영역을 위한 공통 래퍼.
class FilterBar extends StatelessWidget {
const FilterBar({super.key, required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return ShadCard(
title: Text('검색 및 필터', style: theme.textTheme.h3),
child: Align(
alignment: Alignment.centerLeft,
child: Wrap(
spacing: 16,
runSpacing: 16,
children: children,
),
),
);
}
}

View File

@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
/// 페이지 상단 타이틀/설명/액션을 일관되게 출력하는 헤더.
class PageHeader extends StatelessWidget {
const PageHeader({
super.key,
required this.title,
this.subtitle,
this.leading,
this.actions,
this.trailing,
});
final String title;
final String? subtitle;
final Widget? leading;
final List<Widget>? actions;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (leading != null) ...[
leading!,
const SizedBox(width: 16),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.h2),
if (subtitle != null) ...[
const SizedBox(height: 6),
Text(subtitle!, style: theme.textTheme.muted),
],
],
),
),
if (actions != null && actions!.isNotEmpty) ...[
Wrap(
spacing: 12,
runSpacing: 12,
children: actions!,
),
],
if (trailing != null) ...[
const SizedBox(width: 16),
trailing!,
],
],
);
}
}

View File

@@ -0,0 +1,6 @@
const double desktopBreakpoint = 1200;
const double tabletBreakpoint = 960;
bool isDesktop(double width) => width >= desktopBreakpoint;
bool isTablet(double width) => width >= tabletBreakpoint && width < desktopBreakpoint;
bool isMobile(double width) => width < tabletBreakpoint;

View File

@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
/// 공통 모달 다이얼로그.
Future<T?> showSuperportDialog<T>({
required BuildContext context,
required String title,
String? description,
required Widget body,
List<Widget>? actions,
bool barrierDismissible = true,
}) {
return showDialog<T>(
context: context,
barrierDismissible: barrierDismissible,
builder: (dialogContext) {
final theme = ShadTheme.of(dialogContext);
return Dialog(
insetPadding: const EdgeInsets.all(24),
clipBehavior: Clip.antiAlias,
child: ShadCard(
title: Text(title, style: theme.textTheme.h3),
description: description == null
? null
: Text(description, style: theme.textTheme.muted),
footer: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: actions ?? <Widget>[
ShadButton.ghost(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('닫기'),
),
],
),
child: body,
),
);
},
);
}

View File

@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
/// ShadTable.list를 감싼 공통 테이블 래퍼.
class SuperportTable extends StatelessWidget {
const SuperportTable({
super.key,
required this.columns,
required this.rows,
this.columnSpanExtent,
this.rowHeight = 56,
this.onRowTap,
this.emptyLabel = '데이터가 없습니다.',
});
final List<Widget> columns;
final List<List<Widget>> rows;
final TableSpanExtent? Function(int index)? columnSpanExtent;
final double rowHeight;
final void Function(int index)? onRowTap;
final String emptyLabel;
@override
Widget build(BuildContext context) {
if (rows.isEmpty) {
final theme = ShadTheme.of(context);
return Padding(
padding: const EdgeInsets.all(32),
child: Center(
child: Text(emptyLabel, style: theme.textTheme.muted),
),
);
}
final tableRows = [
for (final row in rows)
row
.map(
(cell) => cell is ShadTableCell ? cell : ShadTableCell(child: cell),
)
.toList(),
];
return ShadTable.list(
header: columns
.map(
(cell) => cell is ShadTableCell
? cell
: ShadTableCell.header(child: cell),
)
.toList(),
columnSpanExtent: columnSpanExtent,
rowSpanExtent: (_) => FixedTableSpanExtent(rowHeight),
onRowTap: onRowTap,
children: tableRows,
);
}
}