feat(approvals): Approval Flow v2 프런트엔드 전면 개편

- 환경/라우터 모듈에 approval_flow_v2 토글을 추가하고 FeatureFlags 초기화를 연결 (.env*, lib/core/**)
- ApiClient 빌더·ApiRoutes 확장과 ApprovalRepositoryRemote 리팩터링으로 include·액션 시그니처를 정합화
- ApprovalFlow·ApprovalDraft 엔티티/레포/유즈케이스를 도입해 서버 초안과 단계 액션(승인·회수·재상신)을 지원
- Approval 컨트롤러·히스토리·템플릿 페이지와 공유 위젯을 재작성해 감사 로그·회수 UX·템플릿 CRUD를 반영
- Inbound/Outbound/Rental 컨트롤러·페이지에 결재 섹션을 삽입하고 대시보드 pending 카드 요약을 갱신
- SuperportDialog·FormField 등 공통 위젯을 보강하고 승인 위젯 가이드를 추가해 UI 가이드를 정리
- 결재/재고 테스트 픽스처와 단위·위젯·통합 테스트를 확장하고 flutter_test_config로 스테이징 호스트를 허용
- Approval Flow 레포트/플랜 문서를 업데이트하고 ApprovalFlow_System_Integration_and_ChangePlan.md를 추가
- 실행: flutter analyze, flutter test
This commit is contained in:
JiWoong Sul
2025-10-31 01:05:39 +09:00
parent 259b056072
commit d76f765814
133 changed files with 13878 additions and 947 deletions

View File

@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../approvals/domain/entities/approval.dart';
import '../../../shared/widgets/approval_ui_helpers.dart';
import '../../../../../widgets/components/superport_table.dart';
/// 결재 감사 로그를 표 형태로 렌더링하는 위젯.
class ApprovalAuditLogTable extends StatelessWidget {
const ApprovalAuditLogTable({
super.key,
required this.logs,
required this.dateFormat,
this.pagination,
this.onPageChange,
this.onPageSizeChange,
this.isLoading = false,
});
/// 감사 로그 목록.
final List<ApprovalHistory> logs;
/// 날짜 포맷터.
final DateFormat dateFormat;
/// 페이지네이션 상태.
final SuperportTablePagination? pagination;
/// 페이지 변경 콜백.
final ValueChanged<int>? onPageChange;
/// 페이지 크기 변경 콜백.
final ValueChanged<int>? onPageSizeChange;
/// 로딩 여부.
final bool isLoading;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
if (logs.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 SuperportTable(
columns: const [
Text('행위'),
Text('변경 상태'),
Text('승인자'),
Text('메모'),
Text('일시'),
],
rows: logs.map((log) {
final statusLabel = _buildStatusLabel(log);
final timestamp = dateFormat.format(log.actionAt.toLocal());
return [
ShadBadge.outline(child: Text(log.action.name)),
ApprovalStatusBadge(label: statusLabel, colorHex: log.toStatus.color),
ApprovalApproverCell(
name: log.approver.name,
employeeNo: log.approver.employeeNo,
),
ApprovalNoteTooltip(note: log.note),
Text(timestamp),
];
}).toList(),
rowHeight: 68,
maxHeight: 420,
columnSpanExtent: (index) {
switch (index) {
case 0:
return const FixedTableSpanExtent(120);
case 2:
return const FixedTableSpanExtent(220);
case 3:
return const FixedTableSpanExtent(220);
case 4:
return const FixedTableSpanExtent(160);
default:
return const FixedTableSpanExtent(140);
}
},
pagination: pagination,
onPageChange: onPageChange,
onPageSizeChange: onPageSizeChange,
isLoading: isLoading,
);
}
String _buildStatusLabel(ApprovalHistory log) {
final from = log.fromStatus?.name ?? '시작';
final to = log.toStatus.name;
return '$from$to';
}
}

View File

@@ -0,0 +1,216 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../approvals/domain/entities/approval.dart';
import '../../../../approvals/domain/entities/approval_flow.dart';
import '../../../shared/widgets/approval_ui_helpers.dart';
/// 결재 흐름의 상태 변화를 타임라인으로 표현하는 위젯.
class ApprovalFlowTimeline extends StatelessWidget {
const ApprovalFlowTimeline({
super.key,
required this.flow,
required this.dateFormat,
});
/// 표시할 결재 흐름.
final ApprovalFlow flow;
/// 일시 포맷터.
final DateFormat dateFormat;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final histories = List<ApprovalHistory>.from(flow.histories)
..sort((a, b) => a.actionAt.compareTo(b.actionAt));
final summary = flow.statusSummary;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSummary(theme, summary),
const SizedBox(height: 16),
if (histories.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 20),
decoration: BoxDecoration(
color: theme.colorScheme.secondary.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(12),
),
child: Text('결재 상태 변경 이력이 없습니다.', style: theme.textTheme.muted),
)
else
Column(
children: [
for (var index = 0; index < histories.length; index++)
_TimelineEntry(
history: histories[index],
isFirst: index == 0,
isLast: index == histories.length - 1,
dateFormat: dateFormat,
),
],
),
],
);
}
Widget _buildSummary(ShadThemeData theme, ApprovalFlowStatusSummary summary) {
final requester = flow.requester;
final finalApprover = flow.finalApprover;
final status = flow.status;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
ApprovalStatusBadge(label: status.name, colorHex: status.color),
const SizedBox(width: 12),
Text(
'${summary.totalSteps}단계 · 완료 ${summary.completedSteps} · 대기 ${summary.pendingSteps}',
style: theme.textTheme.small,
),
],
),
const SizedBox(height: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'상신자',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.mutedForeground,
),
),
const SizedBox(height: 4),
Text(
'${requester.name} (${requester.employeeNo})',
style: theme.textTheme.p,
),
if (finalApprover != null) ...[
const SizedBox(height: 12),
Text(
'최종 승인자',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.mutedForeground,
),
),
const SizedBox(height: 4),
Text(
'${finalApprover.name} (${finalApprover.employeeNo})',
style: theme.textTheme.p,
),
],
const SizedBox(height: 12),
Text(
'결재번호 ${flow.approvalNo}',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.mutedForeground,
),
),
],
),
],
);
}
}
class _TimelineEntry extends StatelessWidget {
const _TimelineEntry({
required this.history,
required this.isFirst,
required this.isLast,
required this.dateFormat,
});
final ApprovalHistory history;
final bool isFirst;
final bool isLast;
final DateFormat dateFormat;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final fromStatus = history.fromStatus?.name ?? '시작';
final toStatus = history.toStatus.name;
final timestamp = dateFormat.format(history.actionAt.toLocal());
return Padding(
padding: EdgeInsets.only(top: isFirst ? 0 : 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTimelineIndicator(theme),
const SizedBox(width: 12),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: theme.colorScheme.secondary.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.colorScheme.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
history.action.name,
style: theme.textTheme.small.copyWith(
fontWeight: FontWeight.w700,
),
),
Text(timestamp, style: theme.textTheme.muted),
],
),
const SizedBox(height: 8),
Text('$fromStatus$toStatus', style: theme.textTheme.small),
const SizedBox(height: 12),
ApprovalApproverCell(
name: history.approver.name,
employeeNo: history.approver.employeeNo,
),
if (history.note?.trim().isNotEmpty == true) ...[
const SizedBox(height: 12),
ApprovalNoteTooltip(note: history.note),
],
],
),
),
),
],
),
);
}
Widget _buildTimelineIndicator(ShadThemeData theme) {
final primary = theme.colorScheme.primary;
return SizedBox(
width: 20,
child: Column(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: primary,
borderRadius: BorderRadius.circular(12),
),
),
if (!isLast)
Container(
width: 2,
height: 40,
margin: const EdgeInsets.only(top: 4),
decoration: BoxDecoration(color: primary.withValues(alpha: 0.4)),
),
],
),
);
}
}