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:
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user